Compare commits

...

86 Commits

Author SHA1 Message Date
Hoan Luu Huu
9c9699ea69 improvements when entering application call hook (#489) 2025-03-10 09:26:27 -04:00
Hoan Luu Huu
e48fce08d4 support showing call cloudwatch logs (#490)
* support showing call cloudwatch logs

* wip

* fix review comments

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

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

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

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

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

* making selectedIndex as 0 if it is below 0

* feat/473: fixed styles

* made carrier selector as typeahead selector

* converted account-filter to use typeahead-selector

* styles refactoring

* updated test cases

* added typeahead test case

* added more test cases for typeahead account filter

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

* making selectedIndex as 0 if it is below 0

* feat/473: fixed styles

* made carrier selector as typeahead selector

* converted account-filter to use typeahead-selector

* styles refactoring

* updated test cases

* added typeahead test case

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

* support custom tts streaming vendor

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

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

* fix bugs on google voice cloning

* wip

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

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

* support speechmatics regions

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

* allow system information contains log level and account has enable_debug_log

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

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

* support admin settings private network CIDR

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

* wip

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

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

* add 3 types of aws poly credential

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

* up options ping

* update review comments
2024-04-15 07:19:35 -04:00
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
125 changed files with 8890 additions and 15810 deletions

10
.env
View File

@@ -1,5 +1,5 @@
VITE_API_BASE_URL=http://127.0.0.1:3000/v1
VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
#VITE_API_BASE_URL=http://127.0.0.1:3000/v1
#VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
## enables choosing units and lisenced account call limits
# VITE_APP_ENABLE_ACCOUNT_LIMITS_ALL=true
@@ -25,4 +25,8 @@ VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
## Base url for jambomz webapp
#VITE_APP_BASE_URL="http://jambonz.one"
## Strip publishable key
#VITE_APP_STRIPE_PUBLISHABLE_KEY="pk_test_EChRaX9Tjk8csZZVSeoGqNvu00lsJzjaU1"
#VITE_APP_STRIPE_PUBLISHABLE_KEY="pk_test_EChRaX9Tjk8csZZVSeoGqNvu00lsJzjaU1"
## ignore some specific speech vendors, defined by ADDITIONAL_SPEECH_VENDORS constant
# VITE_APP_DISABLE_ADDITIONAL_SPEECH_VENDORS=true
## AWS region for enabling Recent Call Feature server logs
#VITE_APP_AWS_REGION=us-west-2

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

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

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

11067
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.4",
"version": "0.9.3",
"license": "MIT",
"type": "module",
"engines": {
@@ -42,46 +42,45 @@
},
"dependencies": {
"@jambonz/ui-kit": "^0.0.21",
"dayjs": "^1.11.5",
"@stripe/react-stripe-js": "^2.6.2",
"@stripe/stripe-js": "^3.2.0",
"dayjs": "^1.11.10",
"immutability-helper": "^3.1.1",
"react": "^18.0.0",
"react": "^18.2.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "^18.0.0",
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-router-dom": "^6.3.0",
"wavesurfer.js": "^6.6.3",
"@stripe/react-stripe-js": "^2.1.1",
"@stripe/stripe-js": "^1.54.1"
"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",
"@types/wavesurfer.js": "^6.0.6",
"@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

@@ -111,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(
@@ -126,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
@@ -136,7 +136,7 @@ app.get(
"Content-Disposition": "attachment",
})
.send(pcap); // server: Buffer => client: Blob
}
},
);
app.get(
@@ -144,7 +144,7 @@ app.get(
(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")
path.resolve(process.cwd(), "server", "example.mp3"),
);
res
@@ -154,7 +154,7 @@ app.get(
"Content-Disposition": "attachment",
})
.send(wav); // server: Buffer => client: Blob
}
},
);
app.get(
@@ -162,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 */

View File

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

@@ -33,6 +33,7 @@ import {
API_SUBSCRIPTIONS,
API_CHANGE_PASSWORD,
API_SIGNIN,
API_GOOGLE_CUSTOM_VOICES,
} from "./constants";
import { ROUTE_LOGIN } from "src/router/routes";
import {
@@ -90,6 +91,9 @@ import type {
DeleteAccount,
ChangePassword,
SignIn,
GoogleCustomVoice,
GoogleCustomVoicesQuery,
SpeechSupportedLanguagesAndVoices,
} from "./types";
import { Availability, StatusCodes } from "./types";
import { JaegerRoot } from "./jaeger-types";
@@ -97,7 +101,7 @@ 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 {
@@ -185,7 +189,7 @@ const getAuthHeaders = () => {
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(),
);
};
@@ -221,6 +225,16 @@ export const getBlob = (url: string) => {
});
};
export const postBlobFetch = <Type>(url: string, formdata?: FormData) => {
return fetchTransport<Type>(url, {
method: "POST",
body: formdata,
headers: {
Authorization: `Bearer ${getToken()}`,
},
});
};
/** Simple wrappers for fetchTransport calls to any API, :GET, :POST, :PUT, :DELETE */
export const getFetch = <Type>(url: string) => {
@@ -231,7 +245,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",
@@ -257,7 +271,7 @@ export const deleteFetch = <Type>(url: string) => {
export const deleteFetchWithPayload = <Type, Payload>(
url: string,
payload: Payload
payload: Payload,
) => {
return fetchTransport<Type>(url, {
method: "DELETE",
@@ -287,7 +301,7 @@ export const postLogout = () => {
export const postServiceProviders = (payload: Partial<ServiceProvider>) => {
return postFetch<SidResponse, Partial<ServiceProvider>>(
API_SERVICE_PROVIDERS,
payload
payload,
);
};
@@ -301,24 +315,24 @@ export const postAccount = (payload: Partial<Account>) => {
export const postAccountBucketCredentialTest = (
sid: string,
payload: Partial<BucketCredential>
payload: Partial<BucketCredential>,
) => {
return postFetch<BucketCredentialTestResult, Partial<BucketCredential>>(
`${API_ACCOUNTS}/${sid}/BucketCredentialTest`,
payload
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 =
@@ -332,14 +346,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,
);
};
@@ -355,19 +369,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}`,
);
};
@@ -378,45 +392,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,
);
};
@@ -424,16 +438,26 @@ 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,
);
};
@@ -444,70 +468,86 @@ export const postClient = (payload: Partial<Client>) => {
export const postRegister = (payload: Partial<RegisterRequest>) => {
return postFetch<RegisterResponse, Partial<RegisterRequest>>(
API_REGISTER,
payload
payload,
);
};
export const postSipRealms = (accountSid: string, domain: string) => {
return postFetch<EmptyResponse>(
`${API_ACCOUNTS}/${accountSid}/SipRealms/${domain}`
`${API_ACCOUNTS}/${accountSid}/SipRealms/${domain}`,
);
};
export const postSubscriptions = (payload: Partial<Subscription>) => {
return postFetch<Subscription, Partial<Subscription>>(
API_SUBSCRIPTIONS,
payload
payload,
);
};
export const postChangepassword = (payload: Partial<ChangePassword>) => {
return postFetch<EmptyResponse, Partial<ChangePassword>>(
API_CHANGE_PASSWORD,
payload
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,
);
};
export const postGoogleVoiceCloningKey = (sid: string, file: File) => {
const formData = new FormData();
formData.append("file", file);
return postBlobFetch<EmptyResponse>(
`${API_GOOGLE_CUSTOM_VOICES}/${sid}/VoiceCloningKey`,
formData,
);
};
/** Named wrappers for `putFetch` */
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
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 =
@@ -520,25 +560,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 =
@@ -552,14 +592,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,
);
};
@@ -567,39 +607,60 @@ 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
payload,
);
};
export const putActivationCode = (
code: string,
payload: Partial<ActivationCode>
payload: Partial<ActivationCode>,
) => {
return putFetch<EmptyResponse, Partial<ActivationCode>>(
`${API_ACTIVATION_CODE}/${code}`,
payload
payload,
);
};
export const putGoogleCustomVoice = (
sid: string,
payload: Partial<GoogleCustomVoice>,
) => {
return putFetch<EmptyResponse, Partial<GoogleCustomVoice>>(
`${API_GOOGLE_CUSTOM_VOICES}/${sid}`,
payload,
);
};
/** Named wrappers for `deleteFetch` */
export const deleteUser = (sid: string) => {
@@ -617,7 +678,7 @@ export const deleteApiKey = (sid: string) => {
export const deleteAccount = (sid: string, payload: Partial<DeleteAccount>) => {
return deleteFetchWithPayload<EmptyResponse, Partial<DeleteAccount>>(
`${API_ACCOUNTS}/${sid}`,
payload
payload,
);
};
@@ -627,7 +688,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}`,
);
};
@@ -653,16 +714,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}`,
);
};
@@ -689,6 +750,10 @@ export const deleteClient = (sid: string) => {
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) => {
@@ -701,7 +766,7 @@ export const getServiceProviders = () => {
export const getAccountWebhook = (sid: string) => {
return getFetch<SecretResponse>(
`${API_ACCOUNTS}/${sid}/WebhookSecret?regenerate=true`
`${API_ACCOUNTS}/${sid}/WebhookSecret?regenerate=true`,
);
};
@@ -723,7 +788,7 @@ 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}`,
);
};
@@ -737,10 +802,17 @@ export const getClient = (sid: string) => {
export const getAvailability = (domain: string) => {
return getFetch<Availability>(
`${API_AVAILABILITY}?type=subdomain&value=${domain}`
`${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 = () => {
@@ -751,17 +823,19 @@ export const getRecentCalls = (sid: string, query: Partial<CallQuery>) => {
const qryStr = getQuery<Partial<CallQuery>>(query);
return getFetch<PagedResponse<RecentCall>>(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls?${qryStr}`
: `${API_ACCOUNTS}/${sid}/RecentCalls?${qryStr}`
`${API_ACCOUNTS}/${sid}/RecentCalls?${qryStr}`,
);
};
export const getRecentCall = (sid: string, sipCallId: string) => {
return getFetch<TotalResponse>(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}`
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}`
`${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}`,
);
};
export const getRecentCallLog = (sid: string, callSid: string) => {
return getFetch<string[]>(
`${API_ACCOUNTS}/${sid}/RecentCalls/${callSid}/logs`,
);
};
@@ -769,7 +843,7 @@ export const getPcap = (sid: string, sipCallId: string, method: string) => {
return getBlob(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`,
);
};
@@ -777,30 +851,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,
method: string
method: string,
) => {
return getBlob(
import.meta.env.DEV
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`,
);
};
@@ -810,7 +884,7 @@ 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}`,
);
};
@@ -818,6 +892,23 @@ export const getPrice = () => {
return getFetch<PriceInfo[]>(API_PRICE);
};
export const getSpeechSupportedLanguagesAndVoices = (
sid: string | undefined,
vendor: string,
label: string,
create_new: boolean = false,
) => {
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}` : ""
}${create_new ? "&create_new=true" : ""}`;
return getFetch<SpeechSupportedLanguagesAndVoices>(apiUrl);
};
/** Hooks for components to fetch data with refetch method */
/** :GET /{apiPath} -- this is generic for any fetch of data collections */
@@ -877,7 +968,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,14 +37,26 @@ export interface JaegerAttribute {
value: JaegerValue;
}
export interface WaveSufferSttResult {
export interface WaveSurferSttResult {
vendor: string;
transcript: string;
confidence: number;
language_code: string;
latency?: number;
}
export interface WaveSufferDtmfResult {
export interface WaveSurferTtsLatencyResult {
vendor: string;
latency: string;
isCached: string;
}
export interface WaveSurferGatherSpeechVerbHookLatencyResult {
statusCode: number;
latency: string;
}
export interface WaveSurferDtmfResult {
dtmf: string;
duration: 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 */
@@ -26,6 +26,8 @@ export interface LimitUnitOption {
/** User roles / permissions */
export type UserScopes = "admin" | "service_provider" | "account";
export type LogLevel = "info" | "debug";
export type UserPermissions =
| "VIEW_ONLY"
| "PROVISION_SERVICES"
@@ -63,11 +65,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 */
@@ -124,9 +124,11 @@ export interface ForgotPassword {
}
export interface SystemInformation {
domain_name: string;
sip_domain_name: string;
monitoring_domain_name: string;
domain_name: null | string;
sip_domain_name: null | string;
monitoring_domain_name: null | string;
private_network_cidr: null | string;
log_level: LogLevel;
}
export interface TtsCache {
@@ -262,6 +264,8 @@ export interface Account {
plan_type?: string;
device_to_call_ratio?: number;
trial_end_date?: null | string;
is_active: boolean;
enable_debug_log: boolean;
}
export interface Product {
@@ -314,7 +318,6 @@ export interface Application {
app_json: null | string;
call_hook: null | WebHook;
account_sid: null | string;
messaging_hook: null | WebHook;
application_sid: string;
call_status_hook: null | WebHook;
speech_synthesis_voice: null | string;
@@ -372,6 +375,17 @@ export interface RecentCall {
recording_url?: string;
}
export interface GoogleCustomVoice {
google_custom_voice_sid?: string;
speech_credential_sid?: string;
name: string;
reported_usage: string;
model?: string;
use_voice_cloning_key: number;
voice_cloning_key?: string | null;
voice_cloning_key_file?: File | null;
}
export interface SpeechCredential {
speech_credential_sid: string;
service_provider_sid: null | string;
@@ -383,6 +397,8 @@ 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;
@@ -393,6 +409,7 @@ export interface SpeechCredential {
custom_stt_endpoint_url: null | string;
custom_stt_endpoint: null | string;
client_id: null | string;
client_secret: null | string;
secret: null | string;
nuance_tts_uri: null | string;
nuance_stt_uri: null | string;
@@ -405,8 +422,18 @@ export interface SpeechCredential {
auth_token: null | string;
custom_stt_url: null | string;
custom_tts_url: null | string;
custom_tts_streaming_url: null | string;
label: null | string;
cobalt_server_uri: null | string;
model_id: null | string;
voice_engine: null | string;
engine_version: null | string;
model: null | string;
options: null | string;
deepgram_stt_uri: null | string;
deepgram_tts_uri: null | string;
deepgram_stt_use_tls: number;
speechmatics_stt_uri: null | string;
}
export interface Alert {
@@ -424,6 +451,8 @@ export interface CarrierRegisterStatus {
callId: null | string;
}
export type DtmfType = "rfc2833" | "tones" | "info";
export interface Carrier {
voip_carrier_sid: string;
name: string;
@@ -450,6 +479,8 @@ export interface Carrier {
smpp_inbound_password: null | string;
smpp_enquire_link_interval: number;
register_status: CarrierRegisterStatus;
dtmf_type: DtmfType;
outbound_sip_proxy: string | null;
}
export interface PredefinedCarrier extends Carrier {
@@ -471,6 +502,8 @@ export interface SipGateway extends Gateway {
protocol?: string;
port: number | null;
pad_crypto?: boolean;
send_options_ping?: boolean;
use_sips_scheme?: boolean;
}
export interface SmppGateway extends Gateway {
@@ -494,7 +527,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[];
}
@@ -513,6 +546,9 @@ export interface Client {
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 {
@@ -527,6 +563,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;
@@ -675,3 +718,63 @@ export interface SignIn {
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;
}
export type CartesiaEmotions =
| "anger:lowest"
| "anger:low"
| "anger:high"
| "anger:highest"
| "positivity:lowest"
| "positivity:low"
| "positivity:high"
| "positivity:highest"
| "surprise:lowest"
| "surprise:high"
| "surprise:highest"
| "sadness:lowest"
| "sadness:low"
| "curiosity:low"
| "curiosity:high"
| "curiosity:highest";
export interface CartesiaOptions {
speed: number;
emotion: CartesiaEmotions;
}

View File

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

View File

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

View File

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

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

@@ -1,6 +1,6 @@
import React, { useEffect, forwardRef } from "react";
import { Selector } from "src/components/forms";
import { TypeaheadSelector } from "src/components/forms";
import { hasLength } from "src/utils";
import type { Application } from "src/api/types";
@@ -18,7 +18,7 @@ type ApplicationSelectProps = {
disabled?: boolean;
};
type SelectorRef = HTMLSelectElement;
type SelectorRef = HTMLInputElement;
export const ApplicationSelect = forwardRef<
SelectorRef,
@@ -34,7 +34,7 @@ export const ApplicationSelect = forwardRef<
defaultOption,
...restProps
}: ApplicationSelectProps,
ref
ref,
) => {
useEffect(() => {
if (hasLength(applications) && !applicationSid && !defaultOption) {
@@ -47,7 +47,7 @@ export const ApplicationSelect = forwardRef<
<label htmlFor={id}>
{label} {required && <span>*</span>}
</label>
<Selector
<TypeaheadSelector
ref={ref}
id={id}
name={id}
@@ -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

@@ -11,6 +11,7 @@ type CheckzoneProps = {
hidden?: boolean;
children: React.ReactNode;
initialCheck: boolean;
disabled?: boolean;
handleChecked?: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
@@ -28,8 +29,9 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
children,
initialCheck,
handleChecked,
disabled = false,
}: CheckzoneProps,
ref
ref,
) => {
const [checked, setChecked] = useState(false);
const classesTop = classNames({
@@ -51,6 +53,7 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
<label>
<div className="label-container">
<input
disabled={disabled}
ref={ref}
type="checkbox"
name={name}
@@ -71,7 +74,7 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
{checked && <div className={classesIn}>{children}</div>}
</div>
);
}
},
);
Checkzone.displayName = "Checkzone";

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

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

View File

@@ -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 = {
@@ -53,7 +53,7 @@ export const Selector = forwardRef<SelectorRef, SelectorProps>(
</span>
</div>
);
}
},
);
Selector.displayName = "Selector";

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ export const Modal = ({
</ButtonGroup>
</div>
</div>,
portal
portal,
);
};
@@ -87,7 +87,7 @@ export const ModalForm = ({
</ButtonGroup>
</form>
</div>,
portal
portal,
);
};
@@ -113,7 +113,7 @@ export const ModalClose = ({ children, handleClose }: CloseProps) => {
</ButtonGroup>
</div>
</div>,
portal
portal,
);
};
@@ -147,6 +147,6 @@ export const ModalLoader = ({ children }: LoaderProps) => {
</div>
</div>
</div>,
portal
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

@@ -46,7 +46,7 @@ export const SearchFilter = ({
setAppearance(false);
}
},
[setFilterValue]
[setFilterValue],
);
const handleActive = useCallback(() => {

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

@@ -24,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

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

@@ -44,8 +44,8 @@ export const EditSipRealm = () => {
getAvailability(`${name}.${userData?.account?.root_domain}`)
.then(({ json }) =>
setIsValidDomain(
Boolean(json.available) && hasValue(name) && name.length != 0
)
Boolean(json.available) && hasValue(name) && name.length != 0,
),
)
.catch((error) => {
setErrorMessage(error.msg);

View File

@@ -19,14 +19,14 @@ 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`
`Accounts/${params.account_sid}/TtsCache`,
);
useScopedRedirect(
@@ -36,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... */

View File

@@ -14,7 +14,14 @@ import {
postAccountBucketCredentialTest,
deleteAccount,
} from "src/api";
import { ClipBoard, Icons, Modal, Section, Tooltip } from "src/components";
import {
ClipBoard,
Icons,
Modal,
ScopedAccess,
Section,
Tooltip,
} from "src/components";
import {
Selector,
Checkzone,
@@ -40,6 +47,7 @@ import {
PlanType,
USER_ACCOUNT,
WEBHOOK_METHODS,
STRIPE_PUBLISHABLE_KEY,
} from "src/api/constants";
import { MSG_REQUIRED_FIELDS, MSG_WEBHOOK_FIELDS } from "src/constants";
@@ -66,6 +74,7 @@ import dayjs from "dayjs";
import { EditBoard } from "src/components/editboard";
import { ModalLoader } from "src/components/modal";
import { useAuth } from "src/router/auth";
import { Scope } from "src/store/types";
type AccountFormProps = {
apps?: Application[];
@@ -85,11 +94,14 @@ export const AccountForm = ({
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useApiData<Account[]>("Accounts");
const [invoice] = useApiData<Invoice>("Invoices");
// Dont get Invoices if the environment is self-hosted
const [invoice] = STRIPE_PUBLISHABLE_KEY
? useApiData<Invoice>("Invoices")
: [undefined];
const [userData] = useApiData<CurrentUserData>("Users/me");
const [userCarriers] = useApiData<Carrier[]>(`VoipCarriers`);
const [userSpeechs] = useApiData<SpeechCredential[]>(
`/Accounts/${params.account_sid}/SpeechCredentials`
`/Accounts/${params.account_sid}/SpeechCredentials`,
);
const [name, setName] = useState("");
const [realm, setRealm] = useState("");
@@ -133,6 +145,7 @@ export const AccountForm = ({
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
const [azureConnectionString, setAzureConnectionString] = useState("");
const [endpoint, setEndpoint] = useState("");
const [enableDebugLog, setEnableDebugLog] = useState(false);
/** This lets us map and render the same UI for each... */
const webhooks = [
@@ -183,7 +196,7 @@ export const AccountForm = ({
if (deleteMessage !== "delete my account") {
toastError(
"You must type the delete message correctly in order to delete your account."
"You must type the delete message correctly in order to delete your account.",
);
if (
deleteMessageRef.current &&
@@ -281,7 +294,7 @@ export const AccountForm = ({
} else {
toastError(json.reason);
}
}
},
);
};
@@ -306,7 +319,7 @@ export const AccountForm = ({
return limit.quantity === ""
? deleteAccountLimit(sid, limit.category)
: postAccountLimit(sid, limit);
})
}),
)
.then(() => {
if (limits) {
@@ -357,18 +370,18 @@ export const AccountForm = ({
filtered.find(
(a) =>
a.service_provider_sid !== account.data!.service_provider_sid &&
a.name === name
a.name === name,
)
) {
setMessage(
"The name you have entered is already in use on another one of your accounts."
"The name you have entered is already in use on another one of your accounts.",
);
return;
}
if (filtered.find((a) => a.sip_realm === realm)) {
setMessage(
"The SIP Realm you have entered is already in use on another one of your accounts."
"The SIP Realm you have entered is already in use on another one of your accounts.",
);
return;
}
@@ -377,6 +390,7 @@ export const AccountForm = ({
if (account && account.data) {
putAccount(account.data.account_sid, {
name,
enable_debug_log: enableDebugLog,
...(!ENABLE_HOSTED_SYSTEM && { sip_realm: realm || null }),
webhook_secret: account.data.webhook_secret,
siprec_hook_sid: recId || null,
@@ -446,6 +460,7 @@ export const AccountForm = ({
queue_event_hook: queueHook || null,
registration_hook: regHook || null,
service_provider_sid: currentServiceProvider.service_provider_sid,
enable_debug_log: enableDebugLog,
})
.then(({ json }) => {
toastSuccess("Account created successfully");
@@ -461,6 +476,7 @@ export const AccountForm = ({
/** Set current account data values if applicable -- e.g. "edit mode" */
useEffect(() => {
if (account && account.data) {
setEnableDebugLog(account.data.enable_debug_log);
setName(account.data.name);
if (account.data.sip_realm) {
@@ -518,7 +534,7 @@ export const AccountForm = ({
}
if (account.data.bucket_credential?.secret_access_key) {
setBucketSecretAccessKey(
account.data.bucket_credential?.secret_access_key
account.data.bucket_credential?.secret_access_key,
);
}
if (account.data.bucket_credential?.region) {
@@ -526,7 +542,7 @@ export const AccountForm = ({
}
if (account.data.bucket_credential?.connection_string) {
setAzureConnectionString(
account.data.bucket_credential.connection_string
account.data.bucket_credential.connection_string,
);
}
if (account.data.bucket_credential?.endpoint) {
@@ -536,7 +552,7 @@ export const AccountForm = ({
setRecordAllCalls(account.data.record_all_calls ? true : false);
}
setBucketCredentialChecked(
hasValue(bucketVendor) && bucketVendor.length !== 0
hasValue(bucketVendor) && bucketVendor.length !== 0,
);
if (account.data.bucket_credential?.tags) {
setBucketTags(account.data.bucket_credential?.tags);
@@ -548,11 +564,11 @@ export const AccountForm = ({
setBucketGoogleServiceKey(tmpBucketGoogleServiceKey);
} else if (account.data.bucket_credential?.service_key) {
setBucketGoogleServiceKey(
JSON.parse(account.data.bucket_credential?.service_key)
JSON.parse(account.data.bucket_credential?.service_key),
);
}
setInitialCheckRecordAllCall(
hasValue(bucketVendor) && bucketVendor.length !== 0
hasValue(bucketVendor) && bucketVendor.length !== 0,
);
}
}, [account]);
@@ -572,14 +588,14 @@ export const AccountForm = ({
: { quantity: 0 };
const callSessionRecord = products
? products.find(
(item) => item.name === "concurrent call session"
(item) => item.name === "concurrent call session",
) || { quantity: 0 }
: { quantity: 0 };
const quantity =
(userData.account.device_to_call_ratio || 0) *
(callSessionRecord.quantity || 0) +
(registeredDeviceRecord.quantity || 0);
const { trial_end_date } = userData.account || {};
const { trial_end_date, is_active } = userData.account || {};
switch (pType) {
case PlanType.TRIAL:
setSubscriptionDescription(
@@ -588,10 +604,10 @@ export const AccountForm = ({
} simultaneous calls and ${quantity} registered devices.${
trial_end_date
? ` Your free trial will end on ${dayjs(
trial_end_date
trial_end_date,
).format("MMM DD, YYYY")}.`
: ""
}`
}`,
);
break;
case PlanType.PAID:
@@ -603,15 +619,21 @@ export const AccountForm = ({
CurrencySymbol[invoice.currency || "usd"]
}${(invoice.total || 0) / 100} on ${dayjs
.unix(Number(invoice.next_payment_attempt))
.format("MMM DD, YYYY")}.`
.format("MMM DD, YYYY")}.`,
);
}
break;
case PlanType.FREE:
setSubscriptionDescription(
`You are currently on the Free plan (trial period expired). You are limited to ${callSessionRecord.quantity} simultaneous calls and ${quantity} registered devices`
);
if (is_active) {
setSubscriptionDescription(
`You are currently on the Free plan (trial period expired). You are limited to ${callSessionRecord.quantity} simultaneous calls and ${quantity} registered devices`,
);
} else {
setSubscriptionDescription(
"Your free trial has expired. Please upgrade your subscription to a paid plan to continue service",
);
}
break;
}
// Make sure Account page is alway scroll to top to see subscription
@@ -623,10 +645,10 @@ export const AccountForm = ({
const updateBucketTags = (
index: number,
key: string,
value: typeof bucketTags[number][keyof AwsTag]
value: (typeof bucketTags)[number][keyof AwsTag],
) => {
setBucketTags(
bucketTags.map((b, i) => (i === index ? { ...b, [key]: value } : b))
bucketTags.map((b, i) => (i === index ? { ...b, [key]: value } : b)),
);
};
@@ -693,7 +715,9 @@ export const AccountForm = ({
{isDeleteAccount && (
<Section slim>
<form
className="form form--internal"
className={`form form--internal ${
!account?.data && account?.refetch ? "form--blur" : ""
}`}
onSubmit={handleDeleteAccount}
>
<fieldset>
@@ -898,7 +922,7 @@ export const AccountForm = ({
defaultOption="None"
application={[application.stateVal, application.stateSet]}
applications={apps.filter(
(app) => app.account_sid === account.data!.account_sid
(app) => app.account_sid === account.data!.account_sid,
)}
/>
</fieldset>
@@ -906,7 +930,7 @@ export const AccountForm = ({
})}
{webhooks.map((webhook) => {
const selectOptions = WEBHOOK_METHODS.filter((wm) =>
webhook.prefix === "queue_event_hook" ? wm.name !== "GET" : true
webhook.prefix === "queue_event_hook" ? wm.name !== "GET" : true,
);
return (
@@ -1009,6 +1033,22 @@ export const AccountForm = ({
} cached TTS prompts`}</MS>
</fieldset>
)}
<ScopedAccess scope={Scope.admin} user={user}>
<fieldset>
<label htmlFor="enable_debug_log" className="chk">
<input
id="enable_debug_log"
name="enable_debug_log"
type="checkbox"
onChange={(e) => setEnableDebugLog(e.target.checked)}
checked={enableDebugLog}
/>
<Tooltip text="You can enable debug log for calls only to this account">
Enable debug log for this account
</Tooltip>
</label>
</fieldset>
</ScopedAccess>
{!DISABLE_CALL_RECORDING && (
<>
<fieldset>
@@ -1163,10 +1203,10 @@ export const AccountForm = ({
<code>
{JSON.stringify(
getObscuredGoogleServiceKey(
bucketGoogleServiceKey
bucketGoogleServiceKey,
),
null,
2
2,
)}
</code>
</pre>
@@ -1196,10 +1236,10 @@ export const AccountForm = ({
bucketVendor === BUCKET_VENDOR_S3_COMPATIBLE
? "S3"
: bucketVendor === BUCKET_VENDOR_GOOGLE
? "Google Cloud Storage"
: bucketVendor === BUCKET_VENDOR_AZURE
? "Azure Cloud Storage"
: ""}{" "}
? "Google Cloud Storage"
: bucketVendor === BUCKET_VENDOR_AZURE
? "Azure Cloud Storage"
: ""}{" "}
Tags
</label>
{hasLength(bucketTags) &&
@@ -1239,7 +1279,7 @@ export const AccountForm = ({
type="button"
onClick={() => {
setBucketTags(
bucketTags.filter((g2, i2) => i2 !== i)
bucketTags.filter((g2, i2) => i2 !== i),
);
}}
>

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,7 +40,7 @@ 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;
}
@@ -52,7 +52,7 @@ export const Accounts = () => {
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

@@ -35,7 +35,7 @@ export const ManagePaymentForm = () => {
if (json.status === "success") {
toastSuccess("Payment completed successfully");
navigate(
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
);
} else if (json.status === "action required") {
if (stripe) {

View File

@@ -33,7 +33,7 @@ const SubscriptionForm = () => {
const navigate = useNavigate();
const location = useLocation();
const isModifySubscription = location.pathname.includes(
"modify-subscription"
"modify-subscription",
);
const [billingCharge, setBillingCharge] = useState<Subscription | null>(null);
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
@@ -69,7 +69,7 @@ const SubscriptionForm = () => {
if (json.status === "success") {
toastSuccess("Payment completed successfully");
navigate(
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
);
} else if (json.status === "action required") {
if (stripe) {
@@ -176,7 +176,7 @@ const SubscriptionForm = () => {
.then(() => {
toastSuccess("Downgrade to free plan completed successfully");
navigate(
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
);
})
.catch((error) => {
@@ -201,16 +201,16 @@ const SubscriptionForm = () => {
})
.then(() => {
toastSuccess(
"Your subscription capacity has been successfully modified."
"Your subscription capacity has been successfully modified.",
);
navigate(
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`
`${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`
Please configure a valid credit card for your account and the upgrade will be automatically processed`,
);
})
.finally(() => {
@@ -254,18 +254,18 @@ const SubscriptionForm = () => {
},
]);
const [originalServiceData, setOriginalServiceData] = useState<ServiceData[]>(
[]
[],
);
const initFeesAndCost = (priceData: PriceInfo[]) => {
serviceData.forEach((service) => {
const record = priceData.find(
(item) => item.category === service.category
(item) => item.category === service.category,
);
if (record) {
const price = record.prices.find(
(item) => item.currency === service.currency
(item) => item.currency === service.currency,
);
if (price) {
@@ -299,7 +299,7 @@ const SubscriptionForm = () => {
const getServicePrice = (
service: ServiceData,
capacity: number
capacity: number,
): [number, string, number] => {
let fees = 0;
let feesLabel = "";
@@ -311,7 +311,7 @@ const SubscriptionForm = () => {
} else if (service.billing_scheme === "tiered") {
const filteredTiers = service.tiers
? service.tiers.filter(
(item) => !item.up_to || item.up_to >= capacityNum
(item) => !item.up_to || item.up_to >= capacityNum,
)
: [];
if (filteredTiers.length) {
@@ -360,7 +360,7 @@ const SubscriptionForm = () => {
const updateServiceData = (
index: number,
key: string,
value: typeof serviceData[number][keyof ServiceData]
value: (typeof serviceData)[number][keyof ServiceData],
) => {
setServiceData(
serviceData.map((g, i) =>
@@ -370,8 +370,8 @@ const SubscriptionForm = () => {
[key]: value,
...(key === "capacity" && { cost: Number(value) * g.fees }),
}
: g
)
: g,
),
);
};
@@ -389,7 +389,7 @@ const SubscriptionForm = () => {
if (isModifySubscription && originalServiceData.length > 0) {
setIsDisableSubmitButton(
serviceData[0].capacity === originalServiceData[0].capacity &&
serviceData[1].capacity === originalServiceData[1].capacity
serviceData[1].capacity === originalServiceData[1].capacity,
);
}
setTotal(serviceData.reduce((res, service) => res + service.cost || 0, 0));
@@ -506,7 +506,7 @@ const SubscriptionForm = () => {
updateServiceData(
idx,
"capacity",
e.target.value ? Number(e.target.value) : ""
e.target.value ? Number(e.target.value) : "",
);
}}
/>

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

@@ -16,7 +16,6 @@ import {
LANG_EN_US,
VENDOR_GOOGLE,
LANG_EN_US_STANDARD_C,
useSpeechVendors,
VENDOR_CUSTOM,
} from "src/vendor";
import {
@@ -61,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("");
@@ -78,11 +77,6 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const [tmpStatusWebhook, setTmpStatusWebhook] =
useState<WebHook>(DEFAULT_WEBHOOK);
const [initialStatusWebhook, setInitialStatusWebhook] = useState(false);
const [messageWebhook, setMessageWebhook] =
useState<WebHook>(DEFAULT_WEBHOOK);
const [tmpMessageWebhook, setTmpMessageWebhook] =
useState<WebHook>(DEFAULT_WEBHOOK);
const [initialMessageWebhook, setInitialMessageWebhook] = useState(false);
const [synthVendor, setSynthVendor] =
useState<keyof SynthesisVendors>(VENDOR_GOOGLE);
const [synthLang, setSynthLang] = useState(LANG_EN_US);
@@ -151,22 +145,12 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
initialCheck: initialStatusWebhook,
required: true,
},
{
label: "Messaging",
prefix: "message_webhook",
stateVal: messageWebhook,
tmpStateVal: tmpMessageWebhook,
stateSet: setMessageWebhook,
tmpStateSet: setTmpMessageWebhook,
initialCheck: initialMessageWebhook,
required: false,
},
];
useRedirect<Account>(
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) => {
@@ -174,7 +158,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;
}
@@ -188,11 +172,11 @@ 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;
}
@@ -203,7 +187,6 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
app_json: applicationJson || null,
call_hook: callWebhook || null,
account_sid: accountSid || null,
messaging_hook: messageWebhook || null,
call_status_hook: statusWebhook || null,
speech_synthesis_vendor: synthVendor || null,
speech_synthesis_language: synthLang || null,
@@ -243,7 +226,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) => {
@@ -272,7 +255,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
tv.vendor.substring(VENDOR_CUSTOM.length + 1) +
` (${VENDOR_CUSTOM})`,
value: tv.vendor,
})
}),
);
setttsVendorOptions(vendors.concat(v));
@@ -284,7 +267,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
tv.vendor.substring(VENDOR_CUSTOM.length + 1) +
` (${VENDOR_CUSTOM})`,
value: tv.vendor,
})
}),
);
setSttVendorOptions(vendors.concat(v2));
@@ -297,7 +280,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
(c) =>
c.vendor === synthVendor &&
(!c.account_sid || c.account_sid === accountSid) &&
c.use_for_tts
c.use_for_tts,
);
let c2 = c1
.filter((c) => c.label)
@@ -305,11 +288,11 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
Object.assign({
name: c.label,
value: c.label,
})
}),
);
setTtsLabelOptions(
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2,
);
c1 = fallbackSpeechSynthsisVendor
@@ -317,7 +300,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
(c) =>
c.vendor === fallbackSpeechSynthsisVendor &&
(!c.account_sid || c.account_sid === accountSid) &&
c.use_for_tts
c.use_for_tts,
)
: [];
@@ -327,17 +310,17 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
Object.assign({
name: c.label,
value: c.label,
})
}),
);
setFallbackTtsLabelOptions(
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2
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
c.use_for_stt,
);
c2 = c1
.filter((c) => c.label)
@@ -345,11 +328,11 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
Object.assign({
name: c.label,
value: c.label,
})
}),
);
setSttLabelOptions(
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2,
);
c1 = fallbackSpeechRecognizerVendor
@@ -357,7 +340,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
(c) =>
c.vendor === fallbackSpeechRecognizerVendor &&
(!c.account_sid || c.account_sid === accountSid) &&
c.use_for_stt
c.use_for_stt,
)
: [];
c2 = c1
@@ -366,11 +349,11 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
Object.assign({
name: c.label,
value: c.label,
})
}),
);
setFallbackSttLabelOptions(
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2,
);
}
}, [
@@ -387,6 +370,53 @@ 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) {
@@ -398,7 +428,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
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) {
@@ -425,32 +455,17 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
else setInitialStatusWebhook(false);
}
if (application.data.messaging_hook) {
setMessageWebhook(application.data.messaging_hook);
setTmpMessageWebhook(application.data.messaging_hook);
if (
application.data.messaging_hook.username ||
application.data.messaging_hook.password
)
setInitialMessageWebhook(true);
else setInitialMessageWebhook(false);
}
if (application.data.account_sid)
setAccountSid(application.data.account_sid);
if (application.data.messaging_hook)
setMessageWebhook(application.data.messaging_hook);
if (application.data.speech_synthesis_vendor)
setSynthVendor(
application.data.speech_synthesis_vendor as keyof SynthesisVendors
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)
@@ -458,58 +473,44 @@ 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.speech_synthesis_label) {
setSynthLabel(application.data.speech_synthesis_label);
}
if (application.data.speech_recognizer_label) {
setRecogLabel(application.data.speech_recognizer_label);
}
if (application.data.use_for_fallback_speech) {
setUseForFallbackSpeech(application.data.use_for_fallback_speech > 0);
setInitalCheckFallbackSpeech(
application.data.use_for_fallback_speech > 0
application.data.use_for_fallback_speech > 0,
);
}
if (application.data.fallback_speech_recognizer_vendor) {
setFallbackSpeechRecognizerVendor(
application.data
.fallback_speech_recognizer_vendor as keyof RecognizerVendors
.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_recognizer_label) {
setFallbackSpeechRecognizerLabel(
application.data.fallback_speech_recognizer_label
application.data.fallback_speech_recognizer_language,
);
}
if (application.data.fallback_speech_synthesis_vendor) {
setFallbackSpeechSynthsisVendor(
application.data
.fallback_speech_synthesis_vendor as keyof SynthesisVendors
.fallback_speech_synthesis_vendor as keyof SynthesisVendors,
);
}
if (application.data.fallback_speech_synthesis_language) {
setFallbackSpeechSynthsisLanguage(
application.data.fallback_speech_synthesis_language
application.data.fallback_speech_synthesis_language,
);
}
if (application.data.fallback_speech_synthesis_voice) {
setFallbackSpeechSynthsisVoice(
application.data.fallback_speech_synthesis_voice
);
}
if (application.data.fallback_speech_synthesis_label) {
setFallbackSpeechSynthsisLabel(
application.data.fallback_speech_synthesis_label
application.data.fallback_speech_synthesis_voice,
);
}
}
@@ -549,7 +550,12 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
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>
@@ -603,7 +609,29 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
webhook.stateSet({
...webhook.stateVal,
url: e.target.value,
...(e.target.value.startsWith("ws") && {
method: "GET",
}),
});
if (
e.target.value.startsWith("ws") &&
webhook.prefix === "call_webhook"
) {
const statusWebhook = webhooks.find(
(w) => w.prefix === "status_webhook",
);
if (
statusWebhook &&
((statusWebhook.stateVal?.url || "").length === 0 ||
statusWebhook.stateVal?.url.startsWith("ws"))
) {
statusWebhook.stateSet({
...statusWebhook.stateVal,
url: e.target.value,
method: "GET",
});
}
}
}}
/>
</div>
@@ -619,6 +647,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
method: e.target.value as WebhookMethod,
});
}}
disabled={webhook.stateVal?.url.startsWith("ws")}
options={WEBHOOK_METHODS}
/>
</div>
@@ -674,15 +703,17 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
);
})}
<SpeechProviderSelection
serviceProviderSid={
currentServiceProvider?.service_provider_sid || ""
}
accountSid={accountSid}
credentials={credentials}
synthesis={synthesis}
ttsVendor={[synthVendor, setSynthVendor]}
ttsVendorOptions={ttsVendorOptions}
ttsVoice={[synthVoice, setSynthVoice]}
ttsLang={[synthLang, setSynthLang]}
ttsLabelOptions={ttsLabelOptions}
ttsLabel={[synthLabel, setSynthLabel]}
recognizers={recognizers}
sttVendor={[recogVendor, setRecogVendor]}
sttVendorOptions={sttVendorOptions}
sttLang={[recogLang, setRecogLang]}
@@ -701,8 +732,11 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
}}
>
<SpeechProviderSelection
serviceProviderSid={
currentServiceProvider?.service_provider_sid || ""
}
accountSid={accountSid}
credentials={credentials}
synthesis={synthesis}
ttsVendor={[
fallbackSpeechSynthsisVendor,
setFallbackSpeechSynthsisVendor,
@@ -721,7 +755,6 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
fallbackSpeechSynthsisLabel,
setFallbackSpeechSynthsisLabel,
]}
recognizers={recognizers}
sttVendor={[
fallbackSpeechRecognizerVendor,
setFallbackSpeechRecognizerVendor,

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

View File

@@ -1,44 +1,61 @@
import React, { useEffect, useState } from "react";
import { SpeechCredential } from "src/api/types";
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,
VENDOR_SPEECHMATICS,
VENDOR_PLAYHT,
VENDOR_CARTESIA,
VENDOR_VOXIST,
VENDOR_RIMELABS,
} from "src/vendor";
import {
LabelOptions,
Language,
RecognizerVendors,
SynthesisVendors,
VendorOptions,
Voice,
VoiceLanguage,
} from "src/vendor/types";
type SpeechProviderSelectionProbs = {
accountSid: string;
serviceProviderSid: string;
credentials: SpeechCredential[] | undefined;
synthesis: SynthesisVendors | undefined;
ttsVendor: [
keyof SynthesisVendors,
React.Dispatch<React.SetStateAction<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>>];
recognizers: RecognizerVendors | undefined;
sttVendor: [
keyof RecognizerVendors,
React.Dispatch<React.SetStateAction<keyof RecognizerVendors>>
React.Dispatch<React.SetStateAction<keyof RecognizerVendors>>,
];
sttVendorOptions: VendorOptions[];
sttLang: [string, React.Dispatch<React.SetStateAction<string>>];
@@ -47,293 +64,557 @@ type SpeechProviderSelectionProbs = {
};
export const SpeechProviderSelection = ({
accountSid,
serviceProviderSid,
credentials,
synthesis,
ttsVendor: [synthVendor, setSynthVendor],
ttsVendorOptions,
ttsVoice: [synthVoice, setSynthVoice],
ttsLang: [synthLang, setSynthLang],
ttsLabelOptions,
ttsLabel: [synthLabel, setSynthLabel],
recognizers,
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
)
(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);
}
}
// PlayHT3.0 all voices are listed under english language, all voices can be used for multiple languages
else if (
synthVendor === VENDOR_PLAYHT &&
synthesisSupportedLanguagesAndVoices.tts.some(
(l) => l.value === "english",
)
) {
setSynthesisVoiceOptions(
synthesisSupportedLanguagesAndVoices.tts.find(
(tts) => tts.value === "english",
)!.voices,
);
} else {
setSynthesisVoiceOptions(voicesOpts);
}
}
}, [
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;
}
if (synthVendor === VENDOR_PLAYHT) {
const newLang = json.tts.find(
(lang) => lang.value === LANG_EN_US || lang.value === "english",
);
setSynthLang(newLang!.value);
updateTtsVoice(newLang!.voices[0].value);
return;
}
if (synthVendor === VENDOR_CARTESIA) {
const newLang = json.tts.find((lang) => lang.value === "en");
setSynthLang(newLang!.value);
updateTtsVoice(newLang!.voices[0].value);
return;
}
if (synthVendor === VENDOR_RIMELABS) {
const newLang = json.tts.find((lang) => lang.value === "eng");
setSynthLang(newLang!.value);
updateTtsVoice(newLang!.voices[0].value);
return;
}
/** Google and AWS have different language lists */
/** If the new language doesn't map then default to "en-US" */
let newLang = json.tts.find((lang) => lang.value === synthLang);
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 (
<>
{synthesis && (
<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_DEEPGRAM &&
vendor.value != VENDOR_SONIOX &&
vendor.value !== VENDOR_CUSTOM &&
vendor.value !== VENDOR_COBALT
)}
onChange={(e) => {
const vendor = e.target.value as keyof SynthesisVendors;
setSynthVendor(vendor);
setSynthLabel("");
/** 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);
}}
/>
{hasLength(ttsLabelOptions) && ttsLabelOptions.length > 1 && (
<>
<label htmlFor="synthesis_label">Label</label>
<Selector
id="systhesis_label"
name="systhesis_label"
value={synthLabel}
options={ttsLabelOptions}
onChange={(e) => {
setSynthLabel(e.target.value);
}}
/>
</>
<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_VOXIST &&
vendor.value !== VENDOR_SONIOX &&
vendor.value !== VENDOR_SPEECHMATICS &&
vendor.value !== VENDOR_CUSTOM &&
vendor.value !== VENDOR_COBALT,
)}
{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>
{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={
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) && (
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) => {
shouldUpdateTtsVoice.current = true;
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="custom_vendor_synthesis_lang">Language</label>
<input
id="custom_vendor_synthesis_lang"
type="text"
name="custom_vendor_synthesis_lang"
placeholder="Required"
required
<label htmlFor="synthesis_lang">Language</label>
<Selector
id="synthesis_lang"
name="synthesis_lang"
value={synthLang}
options={synthesisLanguageOptions.sort((a, b) =>
a.name.localeCompare(b.name),
)}
onChange={(e) => {
setSynthLang(e.target.value);
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="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={sttVendorOptions.filter(
(vendor) =>
vendor.value != VENDOR_WELLSAID &&
vendor.value !== VENDOR_CUSTOM
)}
onChange={(e) => {
const vendor = e.target.value as keyof RecognizerVendors;
setRecogVendor(vendor);
setRecogLabel("");
/**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);
}
}}
/>
{hasLength(sttLabelOptions) && sttLabelOptions.length > 1 && (
<>
<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={recognizers[
recogVendor as keyof RecognizerVendors
].map((lang: Language) => ({
name: lang.name,
value: lang.code,
}))}
<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) => {
setRecogLang(e.target.value);
setSynthVoice(e.target.value);
}}
/>
</>
)}
{recogVendor.toString().startsWith(VENDOR_CUSTOM) && (
) : (
<Selector
id="synthesis_voice"
name="synthesis_voice"
value={synthVoice}
options={synthesisVoiceOptions.sort((a, b) =>
a.name.localeCompare(b.name),
)}
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="custom_vendor_recognizer_voice">Language</label>
<input
id="custom_vendor_recognizer_voice"
type="text"
name="custom_vendor_recognizer_voice"
placeholder="Required"
required
<label htmlFor="recognizer_lang">Language</label>
<Selector
id="recognizer_lang"
name="recognizer_lang"
value={recogLang}
options={recogLanguageOptions}
onChange={(e) => {
setRecogLang(e.target.value);
}}
/>
</>
)}
</fieldset>
)}
{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>
</>
);
};

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

@@ -19,6 +19,7 @@ import {
import {
DEFAULT_SIP_GATEWAY,
DEFAULT_SMPP_GATEWAY,
DTMF_TYPE_SELECTION,
FQDN,
FQDN_TOP_LEVEL,
INVALID,
@@ -29,7 +30,7 @@ import {
TECH_PREFIX_MINLENGTH,
USER_ACCOUNT,
} from "src/api/constants";
import { Icons, Section } from "src/components";
import { Icons, Section, Tooltip } from "src/components";
import {
Checkzone,
Message,
@@ -52,16 +53,17 @@ import {
isNotBlank,
} from "src/utils";
import type {
Account,
UseApiDataMap,
Carrier,
SipGateway,
SmppGateway,
PredefinedCarrier,
Sbc,
Smpp,
Application,
import {
type Account,
type UseApiDataMap,
type Carrier,
type SipGateway,
type SmppGateway,
type PredefinedCarrier,
type Sbc,
type Smpp,
type Application,
DtmfType,
} from "src/api/types";
import { setAccountFilter, setLocation } from "src/store/localStore";
import { RegisterStatus } from "./register-status";
@@ -101,6 +103,7 @@ export const CarrierForm = ({
const [e164, setE164] = useState(false);
const [applicationSid, setApplicationSid] = useState("");
const [accountSid, setAccountSid] = useState("");
const [dtmfType, setDtmfType] = useState<DtmfType>("rfc2833");
const [sipRegister, setSipRegister] = useState(false);
const [sipUser, setSipUser] = useState("");
@@ -116,6 +119,9 @@ export const CarrierForm = ({
const [diversion, setDiversion] = useState("");
const [initialDiversion, setInitialDiversion] = useState(false);
const [initialSipProxy, setInitialSipProxy] = useState(false);
const [outboundSipProxy, setOutboundSipProxy] = useState("");
const [smppSystemId, setSmppSystemId] = useState("");
const [smppPass, setSmppPass] = useState("");
const [smppInboundSystemId, setSmppInboundSystemId] = useState("");
@@ -139,6 +145,44 @@ export const CarrierForm = ({
const [smppInboundMessage, setSmppInboundMessage] = useState("");
const [smppOutboundMessage, setSmppOutboundMessage] = useState("");
const validateOutboundSipGateway = (gateway: string): boolean => {
/** validate outbound sip gateway that can be
* ip address
dns name
sip(s):ip address
sip(s):dns name
full sip uri
full sips uri
*/
// firstly checkig it's including sip or sips
if (
gateway.includes(":") &&
!gateway.includes("sip:") &&
!gateway.includes("sips:")
) {
return false;
}
if (gateway.includes("sip:") || gateway.includes("sips:")) {
const sipGateway = gateway.trim().split(":");
if (sipGateway.length === 2) {
const sipGatewayType = getIpValidationType(sipGateway[1]);
if (sipGatewayType === INVALID) {
return false;
}
} else {
return false;
}
}
// check IP address or domain name
else {
const sipGatewayType = getIpValidationType(gateway);
if (sipGatewayType === INVALID) {
return false;
}
}
return true;
};
const setCarrierStates = (obj: Carrier) => {
if (obj) {
setIsActive(obj.is_active);
@@ -204,6 +248,13 @@ export const CarrierForm = ({
setInitialDiversion(false);
}
if (obj.outbound_sip_proxy) {
setOutboundSipProxy(obj.outbound_sip_proxy);
setInitialSipProxy(true);
} else {
setInitialSipProxy(false);
}
if (obj.smpp_system_id) {
setSmppSystemId(obj.smpp_system_id);
}
@@ -216,6 +267,9 @@ export const CarrierForm = ({
if (obj.smpp_inbound_password) {
setSmppInboundPass(obj.smpp_inbound_password);
}
if (obj.dtmf_type) {
setDtmfType(obj.dtmf_type);
}
}
};
@@ -236,7 +290,7 @@ 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) =>
@@ -251,18 +305,18 @@ export const CarrierForm = ({
getIpValidationType(value) === IP &&
g.port === null && { port: 5060 }),
}
: g
)
: 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)),
);
};
@@ -271,8 +325,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();
@@ -289,7 +343,7 @@ export const CarrierForm = ({
smpp_gateway_sid
? putSmppGateway(smpp_gateway_sid, g)
: postSmppGateway({ ...g, voip_carrier_sid });
})
}),
).then(() => {
if (carrierSmppGateways) {
carrierSmppGateways.refetch();
@@ -300,7 +354,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"),
);
}
};
@@ -311,8 +365,8 @@ export const CarrierForm = ({
toastSuccess(
`SMPP ${
g.outbound ? "outbound" : "inbound"
} gateway successfully deleted`
)
} gateway successfully deleted`,
),
);
}
};
@@ -336,10 +390,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) {
@@ -426,7 +483,7 @@ export const CarrierForm = ({
const emptySipIp = sipGateways.find((g) => g.ipv4.trim() === "");
const invalidSipPort = sipGateways.find(
(g) => hasValue(g.port) && !isValidPort(g.port)
(g) => hasValue(g.port) && !isValidPort(g.port),
);
const sipGatewayValidation = getSipValidation();
@@ -499,6 +556,14 @@ export const CarrierForm = ({
}
}
if (
isNotBlank(outboundSipProxy) &&
!validateOutboundSipGateway(outboundSipProxy)
) {
toastError("Please provide a valid SIP Proxy domain or IP address.");
return;
}
if (currentServiceProvider) {
const carrierPayload: Partial<Carrier> = {
name: carrierName.trim(),
@@ -521,13 +586,15 @@ export const CarrierForm = ({
smpp_password: smppPass.trim() || null,
smpp_inbound_system_id: smppInboundSystemId.trim() || null,
smpp_inbound_password: smppInboundPass.trim() || null,
dtmf_type: dtmfType,
outbound_sip_proxy: outboundSipProxy.trim().replaceAll(" ", "") || null,
};
if (carrier && carrier.data) {
putCarrier(
currentServiceProvider.service_provider_sid,
carrier.data.voip_carrier_sid,
carrierPayload
carrierPayload,
)
.then(() => {
if (carrier.data?.voip_carrier_sid) {
@@ -538,7 +605,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) => {
@@ -568,7 +635,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) {
@@ -576,11 +643,11 @@ export const CarrierForm = ({
user?.scope === USER_ACCOUNT
? postPredefinedCarrierTemplateAccount(
accountSid,
predefinedCarrierSid
predefinedCarrierSid,
)
: postPredefinedCarrierTemplate(
currentServiceProvider.service_provider_sid,
predefinedCarrierSid
predefinedCarrierSid,
);
postPredefinedCarrier
@@ -636,7 +703,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>
@@ -685,9 +757,9 @@ export const CarrierForm = ({
(carrier: PredefinedCarrier) => ({
name: carrier.name,
value: carrier.name,
})
}),
)
: []
: [],
)}
onChange={(e) => setPredefinedName(e.target.value)}
/>
@@ -740,7 +812,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
}
@@ -752,10 +824,27 @@ export const CarrierForm = ({
user?.scope !== USER_ACCOUNT
? false
: user.account_sid !== accountSid
? true
: false
? true
: false
}
/>
<label htmlFor="dtmf_type">
<Tooltip
text={
"RFC 2833 is commonly used on VoIP networks. Do not change unless you are certain this carrier does not support it"
}
>
DTMF type
</Tooltip>
</label>
<Selector
id="dtmf_type"
name="dtmf_type"
value={dtmfType}
options={DTMF_TYPE_SELECTION}
onChange={(e) => setDtmfType(e.target.value as DtmfType)}
/>
{user &&
disableDefaultTrunkRouting(user?.scope) &&
accountSid &&
@@ -766,7 +855,7 @@ export const CarrierForm = ({
defaultOption="None"
application={[applicationSid, setApplicationSid]}
applications={applications.filter(
(application) => application.account_sid === accountSid
(application) => application.account_sid === accountSid,
)}
/>
</>
@@ -939,6 +1028,33 @@ export const CarrierForm = ({
/>
</Checkzone>
</fieldset>
<fieldset>
<Checkzone
hidden
name="outbound_sip_proxy"
label="Outbound SIP Proxy"
initialCheck={initialSipProxy}
handleChecked={(e) => {
if (!e.target.checked) {
setOutboundSipProxy("");
}
}}
>
<MS>
Send all calls to this carrier through an outbound proxy
</MS>
<input
id="outbound_sip_proxy"
name="outbound_sip_proxy"
type="text"
value={outboundSipProxy}
placeholder="Outbound Sip Proxy"
onChange={(e) => {
setOutboundSipProxy(e.target.value);
}}
/>
</Checkzone>
</fieldset>
<fieldset>
<label htmlFor="sip_gateways">
SIP gateways<span>*</span>
@@ -994,7 +1110,7 @@ export const CarrierForm = ({
!isNotBlank(e.target.value) &&
getIpValidationType(g.ipv4) !== IP
? null
: Number(e.target.value)
: Number(e.target.value),
);
}}
ref={(ref: HTMLInputElement) =>
@@ -1007,7 +1123,6 @@ export const CarrierForm = ({
<Selector
id={`sip_protocol_${i}`}
name={`sip_protocol${i}`}
placeholder=""
value={g.protocol}
options={SIP_GATEWAY_PROTOCOL_OPTIONS}
onChange={(e) => {
@@ -1020,7 +1135,6 @@ export const CarrierForm = ({
<Selector
id={`sip_netmask_${i}`}
name={`sip_netmask${i}`}
placeholder="32"
value={g.netmask}
options={NETMASK_OPTIONS}
onChange={(e) => {
@@ -1045,7 +1159,7 @@ export const CarrierForm = ({
updateSipGateways(
i,
"is_active",
e.target.checked ? 1 : 0
e.target.checked ? 1 : 0,
);
}}
/>
@@ -1064,7 +1178,7 @@ export const CarrierForm = ({
updateSipGateways(
i,
"inbound",
e.target.checked ? 1 : 0
e.target.checked ? 1 : 0,
);
}}
/>
@@ -1083,36 +1197,78 @@ 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={`sip_pad_crypto_${i}`}
htmlFor={`send_options_ping_${i}`}
className="chk"
>
<input
id={`sip_pad_crypto_${i}`}
name={`sip_pad_crypto_${i}`}
id={`send_options_ping_${i}`}
name={`send_options_ping_${i}`}
type="checkbox"
checked={g.pad_crypto ? true : false}
checked={g.send_options_ping ? true : false}
onChange={(e) => {
updateSipGateways(
i,
"pad_crypto",
e.target.checked
"send_options_ping",
e.target.checked,
);
}}
/>
<div>Pad crypto</div>
<div>Send OPTIONS ping</div>
</label>
</div>
)}
{Boolean(g.outbound) &&
(g.protocol === "tls" || g.protocol === "tls/srtp") && (
<div>
<label
htmlFor={`use_sips_scheme_${i}`}
className="chk"
>
<input
id={`use_sips_scheme_${i}`}
name={`use_sips_scheme_${i}`}
type="checkbox"
checked={g.use_sips_scheme ? true : false}
onChange={(e) => {
updateSipGateways(
i,
"use_sips_scheme",
e.target.checked,
);
}}
/>
<div>Use sips scheme</div>
</label>
</div>
)}
</div>
<button
@@ -1124,15 +1280,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),
);
}
}}
@@ -1246,7 +1402,7 @@ export const CarrierForm = ({
updateSmppGateways(
i,
"port",
Number(e.target.value)
Number(e.target.value),
)
}
ref={(ref: HTMLInputElement) =>
@@ -1265,7 +1421,7 @@ export const CarrierForm = ({
updateSmppGateways(
i,
"use_tls",
e.target.checked
e.target.checked,
)
}
/>
@@ -1287,15 +1443,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),
);
}
}}
@@ -1389,7 +1545,6 @@ export const CarrierForm = ({
<Selector
id={`smpp_netmask_${i}`}
name={`smpp_netmask_${i}`}
placeholder="32"
options={NETMASK_OPTIONS}
value={g.netmask}
onChange={(e) =>
@@ -1404,11 +1559,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

@@ -66,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 = () => {
@@ -87,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) &&
@@ -99,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(
@@ -108,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);
@@ -117,7 +117,7 @@ export const Carriers = () => {
toastSuccess(
<>
Deleted Carrier <strong>{carrier.name}</strong>
</>
</>,
);
})
.catch((error) => {
@@ -130,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]);
@@ -155,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

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

@@ -1,21 +1,24 @@
import { H1 } from "@jambonz/ui-kit";
import React, { useEffect } from "react";
import { useParams } from "react-router-dom";
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}`
`Clients/${params.client_sid}`,
);
/** Handle error toast at top level... */
useEffect(() => {
if (error) {
toastError(error.msg);
navigate(ROUTE_INTERNAL_CLIENTS);
}
}, [error]);

View File

@@ -9,7 +9,7 @@ import {
} from "src/api";
import { USER_ACCOUNT } from "src/api/constants";
import { Account, Client, UseApiDataMap } from "src/api/types";
import { Section } from "src/components";
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";
@@ -30,7 +30,12 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
const [accountSid, setAccountSid] = useState("");
const [password, setPassword] = useState("");
const [username, setUsername] = useState("");
const [isActive, setIsActive] = useState(true);
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) => {
@@ -42,6 +47,9 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
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");
@@ -52,10 +60,10 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
});
} else {
putClient(client.data?.client_sid || "", {
account_sid: accountSid,
username: username,
...(password && { password: password }),
is_active: isActive,
allow_direct_app_calling: allowDirectAppCalling,
allow_direct_queue_calling: allowDirectQueueCalling,
allow_direct_user_calling: allowDirectUserCalling,
})
.then(() => {
toastSuccess("Client updated successfully");
@@ -99,6 +107,9 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
}
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]);
@@ -107,8 +118,6 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
if (!accountSid || !accounts || !acc) return;
if (!acc?.sip_realm) {
setErrorMessage(`Sip realm is not set for the account.`);
} else if (!acc?.device_calling_application_sid) {
setErrorMessage(`Device calling application is not set for the account.`);
} else {
setErrorMessage("");
}
@@ -116,7 +125,12 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
return (
<>
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<form
className={`form form--internal ${
!client?.data && client?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
{errorMessage && <Message message={errorMessage} />}
@@ -134,22 +148,12 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
placeholder="user name"
value={username}
required={true}
disabled={hasValue(client)}
autoComplete="off"
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</div>
<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>
</fieldset>
<fieldset>
<label htmlFor="password">
Password{!hasValue(client) && <span>*</span>}
</label>
@@ -160,14 +164,67 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
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="Used by"
label="Belongs to"
required={true}
defaultOption={false}
disabled={hasValue(client)}

View File

@@ -40,7 +40,7 @@ export const Clients = () => {
setSelectedAccount(
accountSid
? accounts?.find((a: Account) => a.account_sid === accountSid)
: null
: null,
);
return clients
@@ -48,8 +48,8 @@ export const Clients = () => {
return accountSid
? c.account_sid === accountSid
: accounts
? accounts.map((a) => a.account_sid).includes(c.account_sid || "")
: false;
? accounts.map((a) => a.account_sid).includes(c.account_sid || "")
: false;
})
: [];
}, [accountSid, clients, accounts]);
@@ -63,7 +63,7 @@ export const Clients = () => {
toastSuccess(
<>
Deleted sip client <strong>{client.username}</strong>
</>
</>,
);
setClient(null);
refetch();
@@ -126,7 +126,7 @@ export const Clients = () => {
</Link>
</section>
<section className="filters filters--spaced">
<section className="filters filters--multi">
<SearchFilter
placeholder="Filter clients"
filter={[filter, setFilter]}
@@ -179,7 +179,7 @@ export const Clients = () => {
<span>
{
accounts?.find(
(acct) => acct.account_sid === c.account_sid
(acct) => acct.account_sid === c.account_sid,
)?.name
}
</span>

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[];
@@ -141,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
@@ -156,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>

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

@@ -10,9 +10,9 @@ import {
import { Section } from "src/components";
import {
Message,
Selector,
AccountSelect,
ApplicationSelect,
TypeaheadSelector,
} from "src/components/forms";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import {
@@ -41,23 +41,24 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useServiceProviderData<Application[]>("Applications");
const [phoneNumbers] = useServiceProviderData<PhoneNumber[]>("PhoneNumbers");
const [carriers] = useServiceProviderData<Carrier[]>("VoipCarriers");
const [voipCarriers] = useServiceProviderData<Carrier[]>("VoipCarriers");
const [phoneNumberNum, setPhoneNumberNum] = useState("");
const [accountSid, setAccountSid] = useState("");
const [sipTrunkSid, setSipTrunkSid] = useState("");
const [applicationSid, setApplicationSid] = useState("");
const [message, setMessage] = useState("");
const [carriers, setCarriers] = useState<Carrier[]>(voipCarriers || []);
useRedirect<Account>(
accounts,
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,
voipCarriers,
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 +70,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 +91,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) => {
@@ -138,10 +139,29 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
}
}, [carriers, sipTrunkSid]);
// Filter carriers based on account_sid
useEffect(() => {
if (voipCarriers) {
setCarriers(
voipCarriers?.filter(
(carrier) =>
!accountSid ||
(carrier.is_active &&
(!carrier.account_sid || carrier.account_sid === accountSid)),
),
);
}
}, [accountSid, voipCarriers]);
return (
<>
<Section slim>
<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>
@@ -160,11 +180,17 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
disabled={phoneNumber ? true : false}
></input>
</fieldset>
<fieldset>
<AccountSelect
accounts={accounts}
account={[accountSid, setAccountSid]}
/>
</fieldset>
<fieldset>
<label htmlFor="sip_trunk">
Carrier <span>*</span>
</label>
<Selector
<TypeaheadSelector
id="sip_trunk"
name="sip_trunk"
required
@@ -183,21 +209,18 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
disabled={phoneNumber ? true : false}
/>
</fieldset>
<fieldset>
<AccountSelect
accounts={accounts}
account={[accountSid, setAccountSid]}
/>
</fieldset>
<fieldset>
<ApplicationSelect
defaultOption="Choose application"
application={[applicationSid, setApplicationSid]}
applications={
applications
? applications.filter(
(application) => application.account_sid === accountSid
)
? applications
.filter(
(application) => application.account_sid === accountSid,
)
.sort((a, b) => a.name.localeCompare(b.name))
: []
}
/>

View File

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

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

View File

@@ -98,7 +98,7 @@ export const CallTracing = ({ call }: CallTracingProps) => {
rootGroup.children = buildChildren(
rootGroup.level + 1,
rootGroup,
groups
groups,
);
setJaegerGroup(rootGroup);
}
@@ -108,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;

View File

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

View File

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

View File

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

@@ -6,18 +6,19 @@ 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/src/plugin/regions";
import TimelinePlugin from "wavesurfer.js/src/plugin/timeline";
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,
WaveSufferDtmfResult,
WaveSufferSttResult,
WaveSurferDtmfResult,
WaveSurferGatherSpeechVerbHookLatencyResult,
WaveSurferSttResult,
WaveSurferTtsLatencyResult,
} from "src/api/jaeger-types";
import {
getSpanAttributeByName,
getSpansByName,
getSpansByNameRegex,
getSpansFromJaegerRoot,
} from "./utils";
@@ -38,28 +39,39 @@ export const Player = ({ call }: PlayerProps) => {
const [isReady, setIsReady] = useState(false);
const [playBackTime, setPlayBackTime] = useState("");
const [jaegerRoot, setJeagerRoot] = useState<JaegerRoot>();
const [waveSufferRegionData, setWaveSufferRegionData] =
useState<WaveSufferSttResult | null>();
const [waveSufferDtmfData, setWaveSufferDtmfData] =
useState<WaveSufferDtmfResult | null>();
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 wavesurferTimelineId = `timeline-${wavesurferId}`;
const waveSufferRef = useRef<WaveSurfer | null>(null);
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 (waveSufferRef.current) {
const r = waveSufferRef.current.regions.list[s.spanId];
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"
"duration",
);
if (dtmfValue && durationValue) {
const start =
@@ -67,29 +79,28 @@ export const Player = ({ call }: PlayerProps) => {
1_000_000_000;
const duration =
Number(durationValue.value.stringValue.replace("ms", "")) / 1_000;
// as duration of DTMF is short, cannot be shown in wavesuffer,
// 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 = waveSufferRef.current.addRegion({
const region = waveSurferRegionsPluginRef.current.addRegion({
id: s.spanId,
start,
end,
color: "rgba(138, 43, 226, 0.15)",
drag: false,
loop: false,
resize: false,
});
changeRegionMouseStyle(region);
const att: WaveSufferDtmfResult = {
const att: WaveSurferDtmfResult = {
dtmf: dtmfValue.value.stringValue,
duration: durationValue.value.stringValue,
};
region.on("click", () => {
setWaveSufferDtmfData(att);
setWaveSurferDtmfData(att);
});
}
}
@@ -110,13 +121,52 @@ export const Player = ({ call }: PlayerProps) => {
});
};
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
channel = 0,
) => {
if (waveSufferRef.current) {
const r = waveSufferRef.current.regions.list[s.spanId];
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 +
@@ -124,30 +174,46 @@ export const Player = ({ call }: PlayerProps) => {
const end =
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
const region = waveSufferRef.current.addRegion({
id: s.spanId,
start,
end,
color: "rgba(255, 0, 0, 0.15)",
drag: false,
loop: false,
resize: false,
});
changeRegionMouseStyle(region, channel);
const endSpeechTime = getSilenceStartTime(start, end, channel);
const [sttResult] = getSpanAttributeByName(s.attributes, "stt.result");
let att: WaveSufferSttResult;
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"
"stt.resolve",
);
if (sttResolve && sttResolve.value.stringValue === "timeout") {
att = {
@@ -167,17 +233,121 @@ export const Player = ({ call }: PlayerProps) => {
}
}
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", () => {
setWaveSufferRegionData(att);
setWaveSurferRegionData(att);
});
}
}
};
const buildWavesufferRegion = () => {
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 [startPoint] = getSpansByName(spans, "background-listen:listen");
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{/);
@@ -185,6 +355,7 @@ export const Player = ({ call }: PlayerProps) => {
drawSttRegionForSpan(s, startPoint);
});
// Trasscription
const transcribeSpans = getSpansByNameRegex(spans, /stt-listen:/);
transcribeSpans.forEach((cs) => {
// Channel start from 0
@@ -192,14 +363,38 @@ export const Player = ({ call }: PlayerProps) => {
drawSttRegionForSpan(
cs,
startPoint,
channel > 0 ? channel - 1 : channel
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);
});
}
}
};
@@ -218,7 +413,7 @@ export const Player = ({ call }: PlayerProps) => {
};
useEffect(() => {
buildWavesufferRegion();
buildWavesurferRegion();
}, [jaegerRoot, isReady]);
useEffect(() => {
@@ -251,8 +446,9 @@ export const Player = ({ call }: PlayerProps) => {
}
useEffect(() => {
if (waveSufferRef.current !== null || !record) return;
waveSufferRef.current = WaveSurfer.create({
if (waveSurferRef.current !== null || !record) return;
waveSurferRegionsPluginRef.current = RegionsPlugin.create();
waveSurferRef.current = WaveSurfer.create({
container: `#${wavesurferId}`,
waveColor: "#da1c5c",
progressColor: "grey",
@@ -260,62 +456,68 @@ export const Player = ({ call }: PlayerProps) => {
cursorWidth: 1,
cursorColor: "lightgray",
normalize: true,
responsive: true,
fillParent: true,
splitChannels: true,
scrollParent: true,
autoScroll: true,
splitChannels: [],
minPxPerSec: 100,
plugins: [
RegionsPlugin.create({}),
waveSurferRegionsPluginRef.current,
TimelinePlugin.create({
container: `#${wavesurferTimelineId}`,
timeInterval: 0.2,
primaryLabelInterval: 5,
secondaryLabelInterval: 1,
style: {
fontSize: "15px",
color: "#000000",
fontWeight: "bold",
},
}),
],
});
waveSufferRef.current.load(record?.data_url);
waveSurferRef.current.load(record?.data_url);
// All event should be after load
waveSufferRef.current.on("finish", () => {
waveSurferRef.current.on("finish", () => {
setIsPlaying(false);
});
waveSufferRef.current.on("play", () => {
waveSurferRef.current.on("play", () => {
setIsPlaying(true);
});
waveSufferRef.current.on("pause", () => {
waveSurferRef.current.on("pause", () => {
setIsPlaying(false);
});
waveSufferRef.current.on("ready", () => {
waveSurferRef.current.on("ready", () => {
setIsReady(true);
setPlayBackTime(formatTime(waveSufferRef.current?.getDuration() || 0));
setPlayBackTime(formatTime(waveSurferRef.current?.getDuration() || 0));
});
waveSufferRef.current.on("audioprocess", () => {
setPlayBackTime(formatTime(waveSufferRef.current?.getCurrentTime() || 0));
waveSurferRef.current.on("audioprocess", () => {
setPlayBackTime(formatTime(waveSurferRef.current?.getCurrentTime() || 0));
});
}, [record]);
const togglePlayback = () => {
if (waveSufferRef.current) {
if (waveSurferRef.current) {
if (!isPlaying) {
waveSufferRef.current.play();
waveSurferRef.current.play();
} else {
waveSufferRef.current.pause();
waveSurferRef.current.pause();
}
}
};
const setPlaybackJump = (delta: number) => {
if (waveSufferRef.current) {
const idx = waveSufferRef.current.getCurrentTime() + delta;
if (waveSurferRef.current) {
const idx = waveSurferRef.current.getCurrentTime() + delta;
const value =
idx <= 0
? 0
: idx >= waveSufferRef.current.getDuration()
? waveSufferRef.current.getDuration() - 1
: idx;
waveSufferRef.current.setCurrentTime(value);
: idx >= waveSurferRef.current.getDuration()
? waveSurferRef.current.getDuration() - 1
: idx;
waveSurferRef.current.setTime(value);
setPlayBackTime(formatTime(value));
}
};
@@ -326,7 +528,6 @@ export const Player = ({ call }: PlayerProps) => {
<>
<div className="media-container">
<div id={wavesurferId} />
<div id={wavesurferTimelineId} />
<div className="media-container__center">
<strong>{playBackTime}</strong>
</div>
@@ -404,19 +605,20 @@ export const Player = ({ call }: PlayerProps) => {
checked={regionChecked}
onChange={(e) => {
setRegionChecked(e.target.checked);
if (waveSufferRef.current) {
const regionsList = waveSufferRef.current.regions.list;
if (waveSurferRegionsPluginRef.current) {
const regionsList =
waveSurferRegionsPluginRef.current.getRegions();
for (const [, region] of Object.entries(regionsList)) {
region.element.style.display = e.target.checked ? "" : "none";
}
}
}}
/>
<div>Overlay STT and DTMF events</div>
<div>Show latencies</div>
</label>
</div>
{waveSufferRegionData && (
<ModalClose handleClose={() => setWaveSufferRegionData(null)}>
{waveSurferRegionData && (
<ModalClose handleClose={() => setWaveSurferRegionData(null)}>
<div className="spanDetailsWrapper__header">
<P>
<strong>Speech to text result</strong>
@@ -424,43 +626,53 @@ export const Player = ({ call }: PlayerProps) => {
</div>
<div className="spanDetailsWrapper">
<div className="spanDetailsWrapper__detailsWrapper">
{waveSufferRegionData.vendor && (
{waveSurferRegionData.vendor && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Vendor:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferRegionData.vendor}
{waveSurferRegionData.vendor}
</div>
</div>
)}
{waveSufferRegionData.confidence !== 0 && (
{waveSurferRegionData.confidence !== 0 && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Confidence:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferRegionData.confidence}
{waveSurferRegionData.confidence}
</div>
</div>
)}
{waveSufferRegionData.language_code && (
{waveSurferRegionData.language_code && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Language code:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferRegionData.language_code}
{waveSurferRegionData.language_code}
</div>
</div>
)}
{waveSufferRegionData.transcript && (
{waveSurferRegionData.transcript && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Transcript:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferRegionData.transcript}
{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>
)}
@@ -468,8 +680,8 @@ export const Player = ({ call }: PlayerProps) => {
</div>
</ModalClose>
)}
{waveSufferDtmfData && (
<ModalClose handleClose={() => setWaveSufferDtmfData(null)}>
{waveSurferDtmfData && (
<ModalClose handleClose={() => setWaveSurferDtmfData(null)}>
<div className="spanDetailsWrapper__header">
<P>
<strong>Dtmf result</strong>
@@ -482,7 +694,7 @@ export const Player = ({ call }: PlayerProps) => {
<strong>Dtmf:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferDtmfData.dtmf}
{waveSurferDtmfData.dtmf}
</div>
</div>
@@ -491,7 +703,78 @@ export const Player = ({ call }: PlayerProps) => {
<strong>Duration:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferDtmfData.duration}
{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>

View File

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

View File

@@ -11,12 +11,12 @@ export const getSpansFromJaegerRoot = (trace: JaegerRoot) => {
!(
attr.key.startsWith("telemetry") ||
attr.key.startsWith("internal")
)
),
);
value.attributes = attrs;
spans.push(value);
});
}
},
);
});
spans.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
@@ -25,14 +25,14 @@ export const getSpansFromJaegerRoot = (trace: JaegerRoot) => {
export const getSpansByName = (
spans: JaegerSpan[],
name: string
name: string,
): JaegerSpan[] => {
return spans.filter((s) => s.name === name);
};
export const getSpansByNameRegex = (
spans: JaegerSpan[],
pattern: RegExp
pattern: RegExp,
): JaegerSpan[] => {
const matcher = new RegExp(pattern);
return spans.filter((s) => matcher.test(s.name));
@@ -40,7 +40,7 @@ export const getSpansByNameRegex = (
export const getSpanAttributeByName = (
attr: JaegerAttribute[],
name: string
name: string,
): JaegerAttribute[] => {
return attr.filter((a) => a.key === name);
};

View File

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

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

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

@@ -12,7 +12,6 @@ import {
} from "src/api";
import { ROUTE_INTERNAL_SPEECH } from "src/router/routes";
import {
getHumanDateTime,
isUserAccountScope,
hasLength,
hasValue,
@@ -49,27 +48,27 @@ 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);
@@ -81,7 +80,7 @@ export const SpeechServices = () => {
{credential.vendor}
{credential.label ? ` (${credential.label})` : ""}
</strong>{" "}
</>
</>,
);
})
.catch((error) => {
@@ -97,7 +96,7 @@ export const SpeechServices = () => {
setApiUrl(`Accounts/${accountSid}/SpeechCredentials`);
} else {
setApiUrl(
`ServiceProviders/${currentServiceProvider?.service_provider_sid}/SpeechCredentials`
`ServiceProviders/${currentServiceProvider?.service_provider_sid}/SpeechCredentials`,
);
}
}, [currentServiceProvider, accountSid]);
@@ -112,7 +111,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]}
@@ -149,7 +148,7 @@ export const SpeechServices = () => {
Vendor:{" "}
{credential.vendor.startsWith(VENDOR_CUSTOM)
? credential.vendor.substring(
VENDOR_CUSTOM.length + 1
VENDOR_CUSTOM.length + 1,
)
: credential.vendor}
</strong>
@@ -178,24 +177,6 @@ export const SpeechServices = () => {
<span>{getUsage(credential)}</span>
</div>
</div>
<div>
<div
className={`i txt--${
credential.last_used ? "teal" : "grey"
}`}
>
{credential.last_used ? (
<Icons.CheckCircle />
) : (
<Icons.XCircle />
)}
<span>
{credential.last_used
? getHumanDateTime(credential.last_used)
: "Never used"}
</span>
</div>
</div>
<div>
<CredentialStatus cred={credential} />
</div>

View File

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

View File

@@ -14,7 +14,7 @@ export const getObscuredGoogleServiceKey = (key: GoogleServiceKey) => {
return {
...key,
private_key: `${keyHeader}${getObscuredSecret(
key.private_key.slice(keyHeader.length, key.private_key.length)
key.private_key.slice(keyHeader.length, key.private_key.length),
)}`,
};
};
@@ -23,15 +23,15 @@ export const getUsage = (cred: SpeechCredential) => {
return cred.use_for_tts && cred.use_for_stt
? "TTS/STT"
: cred.use_for_tts
? "TTS"
: cred.use_for_stt
? "STT"
: "Not in use";
? "TTS"
: cred.use_for_stt
? "STT"
: "Not in use";
};
export const getStatus = (
cred: SpeechCredential,
testResult: CredentialTestResult
testResult: CredentialTestResult,
): CredentialStatus => {
if (
(cred.use_for_tts &&
@@ -60,7 +60,7 @@ export const getStatus = (
export const getReason = (
cred: SpeechCredential,
testResult: CredentialTestResult
testResult: CredentialTestResult,
) => {
const ok = "Connection test successful";

View File

@@ -55,7 +55,7 @@ export const UserForm = ({ user }: UserFormProps) => {
const [email, setEmail] = useState("");
const [initialPassword, setInitialPassword] = useState("");
const [scope, setScope] = useState<UserScopes | null>(
currentUser?.scope || null
currentUser?.scope || null,
);
const [isActive, setIsActive] = useState(true);
const [forceChange, setForceChange] = useState(true);
@@ -80,7 +80,7 @@ export const UserForm = ({ user }: UserFormProps) => {
toastSuccess(
<>
Deleted user <strong>{user?.data?.name}</strong>
</>
</>,
);
handleSelfDetete();
})
@@ -183,7 +183,12 @@ export const UserForm = ({ user }: UserFormProps) => {
return (
<>
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<form
className={`form form--internal ${
!user?.data && user?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
</fieldset>
@@ -197,7 +202,8 @@ export const UserForm = ({ user }: UserFormProps) => {
options={
currentUser?.scope === USER_SP
? USER_SCOPE_SELECTION.filter(
(opt) => opt.value !== USER_ADMIN && opt.value !== "all"
(opt) =>
opt.value !== USER_ADMIN && opt.value !== "all",
)
: USER_SCOPE_SELECTION.filter((e) => e.value !== "all")
}

View File

@@ -60,7 +60,7 @@ export const Users = () => {
if (scopeFilter !== "all" && accountSid) {
return serviceProviderUsers?.filter(
(e) => e.scope === scopeFilter && accountSid === e.account_sid
(e) => e.scope === scopeFilter && accountSid === e.account_sid,
);
}
@@ -71,7 +71,7 @@ export const Users = () => {
}, [accountSid, scopeFilter, users, accounts, currentServiceProvider]);
const filteredUsers = useFilteredResults<User>(filter, usersFiltered)?.sort(
sortUsersAlpha
sortUsersAlpha,
);
useEffect(() => {
@@ -88,7 +88,7 @@ export const Users = () => {
</Icon>
</Link>
</section>
<section className="filters filters--mix">
<section className="filters filters--multi">
<section>
<SearchFilter
placeholder="Filter users"
@@ -137,8 +137,8 @@ export const Users = () => {
{user.scope === USER_ADMIN
? "All"
: user.account_name
? `Account: ${user.account_name}`
: `Service Provider: ${user.service_provider_name}`}
? `Account: ${user.account_name}`
: `Service Provider: ${user.service_provider_name}`}
</div>
<div className="item__actions">
<Link

View File

@@ -63,7 +63,7 @@ export const CreatePassword = () => {
<li>Contain at least one special character</li>
)}
</ul>
</>
</>,
);
return;
}
@@ -90,7 +90,7 @@ export const CreatePassword = () => {
navigate(
userData.scope !== USER_ACCOUNT
? ROUTE_INTERNAL_ACCOUNTS
: ROUTE_INTERNAL_APPLICATIONS
: ROUTE_INTERNAL_APPLICATIONS,
);
} else {
setMessage(MSG_SOMETHING_WRONG);

View File

@@ -24,7 +24,7 @@ export const ForgotPassword = () => {
.then((response) => {
if (response.status === StatusCodes.NO_CONTENT) {
toastSuccess(
"A password reset email has been sent to your email. Please check your inbox (and, possibly, spam folder) and follow the instructions to reset your password."
"A password reset email has been sent to your email. Please check your inbox (and, possibly, spam folder) and follow the instructions to reset your password.",
);
navigate(ROUTE_LOGIN);
} else {

View File

@@ -20,7 +20,7 @@ export const ResetPassword = () => {
setMessage("");
if (newPassword !== confirmNewPassword) {
setMessage(
"The confirmation password does not match the new password. Please ensure both passwords are identical."
"The confirmation password does not match the new password. Please ensure both passwords are identical.",
);
return;
}

View File

@@ -44,8 +44,8 @@ export const RegisterChooseSubdomain = () => {
getAvailability(`${name}.${rootDomain}`)
.then(({ json }) =>
setIsValidDomain(
Boolean(json.available) && hasValue(name) && name.length != 0
)
Boolean(json.available) && hasValue(name) && name.length != 0,
),
)
.catch((error) => {
setErrorMessage(error.msg);

View File

@@ -19,5 +19,5 @@ createRoot(root).render(
</AuthProvider>
</BrowserRouter>
</StateProvider>
</React.StrictMode>
</React.StrictMode>,
);

View File

@@ -84,7 +84,7 @@ export const parseJwt = (token: string) => {
.map((c) => {
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
})
.join("")
.join(""),
);
return JSON.parse(jsonPayload);
@@ -117,7 +117,7 @@ export const useProvideAuth = (): AuthStateContext => {
navigate(
userData.scope !== USER_ACCOUNT
? ROUTE_INTERNAL_ACCOUNTS
: `${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`
: `${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`,
);
}
})
@@ -132,7 +132,7 @@ export const useProvideAuth = (): AuthStateContext => {
navigate(
userData.scope !== USER_ACCOUNT
? ROUTE_INTERNAL_ACCOUNTS
: `${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`
: `${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`,
);
}

View File

@@ -17,7 +17,7 @@ export const genericAction = (state: State, action: Action<keyof State>) => {
export const serviceProvidersAction = (
state: State,
action: Action<keyof State>
action: Action<keyof State>,
) => {
// Sorts for consistent list view
action.payload = (<ServiceProvider[]>action.payload).sort(sortLocaleName);
@@ -33,7 +33,7 @@ export const serviceProvidersAction = (
const serviceProvider = action.payload.find(
(sp: ServiceProvider) =>
sp.service_provider_sid ===
state.currentServiceProvider?.service_provider_sid
state.currentServiceProvider?.service_provider_sid,
);
// The `serviceProvider` will be undefined if this is after a DELETE
// For this case we want to just reset to the first provider in the list
@@ -51,7 +51,7 @@ export const serviceProvidersAction = (
export const currentServiceProviderAction = (
state: State,
action: Action<keyof State>
action: Action<keyof State>,
) => {
// Set MS Teams Tenants ACL condition
state.accessControl.hasMSTeamsFqdn = (<ServiceProvider>action.payload)

View File

@@ -109,7 +109,7 @@ const storeLocation = "location";
export const setLocation = () => {
return localStorage.setItem(
storeLocation,
window.location.pathname.split("/")[2]
window.location.pathname.split("/")[2],
);
};

View File

@@ -19,14 +19,45 @@
grid-gap: ui-vars.$px02;
}
}
> :first-child {
margin-left: auto;
}
&--multi {
overflow-x: auto;
white-space: nowrap;
grid-gap: ui-vars.$px02;
> :first-child {
margin-left: auto;
@media (max-width: 1400px) {
display: grid;
grid-template-columns: repeat(4, 1fr);
> * {
justify-self: end;
}
}
@media (max-width: 1200px) {
display: grid;
grid-template-columns: repeat(3, 1fr);
> * {
justify-self: end;
}
}
@media (max-width: 1000px) {
display: grid;
grid-template-columns: repeat(2, 1fr);
> * {
justify-self: end;
}
}
@media (max-width: 500px) {
display: grid;
grid-template-columns: repeat(1, 1fr);
> * {
justify-self: end;
}
}
}

View File

@@ -105,6 +105,10 @@ fieldset {
}
}
&--blur {
pointer-events: none;
}
label {
@include ui-mixins.m();
@include ui-mixins.font-medium();
@@ -227,7 +231,7 @@ fieldset {
}
&:nth-child(2) {
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
margin-top: ui-vars.$px02;
> div:last-child {
@@ -294,3 +298,58 @@ fieldset {
.bucket_tag {
@extend .lcr;
}
.customVoice {
padding: ui-vars.$px02;
border-radius: ui-vars.$px01;
border: 2px solid ui-vars.$grey;
max-width: ui-vars.$width-mobile;
position: relative;
> div {
display: grid;
grid-gap: ui-vars.$px02;
align-items: center;
&:nth-child(1) {
grid-template-columns: [col] 100%;
}
&:nth-child(2) {
grid-template-columns: [col] calc(40% - #{ui-vars.$px02 * 2}) [col] 60%;
margin-top: ui-vars.$px02;
}
&:nth-child(3) {
grid-template-columns: [col] 100%;
margin-top: ui-vars.$px02;
}
&:nth-child(4) {
grid-template-columns: [col] 100%;
margin-top: ui-vars.$px02;
}
&:nth-child(5) {
grid-template-columns: [col] 100%;
margin-top: ui-vars.$px02;
}
}
> button {
position: absolute;
right: 0;
bottom: 50%;
transform: translate3d(50%, 50%, 0);
@include mixins.small() {
top: auto;
bottom: auto;
transform: none;
position: relative;
margin-top: ui-vars.$px02;
display: flex;
margin-left: auto;
}
}
}

View File

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

View File

@@ -29,14 +29,14 @@ import type { UserData } from "src/store/types";
import { getQueryFilter } from "src/store/localStore";
export const hasValue = <Type>(
variable: Type | null | undefined
variable: Type | null | undefined,
): variable is NonNullable<Type> => {
return variable !== null && variable !== undefined;
};
export const hasLength = <Type>(
variable: Type[] | null | undefined,
minlength = 0
minlength = 0,
): variable is NonNullable<Type[]> => {
return hasValue(variable) && variable.length > minlength;
};
@@ -52,7 +52,7 @@ export const isObject = (obj: unknown) => {
export const isValidPasswd = (
password: string,
passwordSettings: PasswordSettings
passwordSettings: PasswordSettings,
) => {
if (passwordSettings) {
return (
@@ -78,17 +78,33 @@ export const isValidPort = (port: number) => {
export const getIpValidationType = (ipv4: string): IpType => {
const type =
/^((25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])$/.test(
ipv4.trim()
ipv4.trim(),
)
? IP
: /^([a-zA-Z0-9][^.]*)(\.[^.]+){2,}$/.test(ipv4.trim())
? FQDN
: /^([a-zA-Z][^.]*)(\.[^.]+)$/.test(ipv4.trim())
? FQDN_TOP_LEVEL
: INVALID;
? FQDN
: /^([a-zA-Z][^.]*)(\.[^.]+)$/.test(ipv4.trim())
? FQDN_TOP_LEVEL
: INVALID;
return type;
};
function isValidIPV4(ip: string): boolean {
const ipv4Pattern =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipv4Pattern.test(ip);
}
function isValidCIDR(cidr: string): boolean {
const cidrPattern =
/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(\/([0-9]|[1-2][0-9]|3[0-2]))$/;
return cidrPattern.test(cidr);
}
export function isvalidIpv4OrCidr(input: string): boolean {
return isValidIPV4(input) || isValidCIDR(input);
}
export const getObscured = (str: string, sub = 4, char = "*") => {
const len = str.length - sub;
const obscured = str.substring(0, len).replace(/[a-zA-Z0-9]/g, char);
@@ -114,16 +130,16 @@ export const getHumanDateTime = (date: string, fallbackText = "Never used") => {
currDate.setHours(0, 0, 0, 0);
argDate.setHours(0, 0, 0, 0);
const daysDiff = Math.round(
(currDate.getTime() - argDate.getTime()) / 1000 / 60 / 60 / 24
(currDate.getTime() - argDate.getTime()) / 1000 / 60 / 60 / 24,
);
return daysDiff > 1
? `${daysDiff} days ago`
: daysDiff === 1
? "Yesterday"
: daysDiff === 0
? "Today"
: fallbackText;
? "Yesterday"
: daysDiff === 0
? "Today"
: fallbackText;
};
export const formatPhoneNumber = (number: string) => {
@@ -150,7 +166,7 @@ export const formatTime = (seconds: number) => {
export const sortLocaleName = (
a: Required<{ name: string }>,
b: Required<{ name: string }>
b: Required<{ name: string }>,
) => a.name.localeCompare(b.name);
export const getUserScope = (user: User): UserScopes => {
@@ -172,7 +188,7 @@ export const isUserAccountScope = (accountSid: string, user?: UserData) => {
export const checkSelectOptions = (
user?: UserData,
resource?: SpeechCredential | Carrier
resource?: SpeechCredential | Carrier,
) => {
if (user?.scope === USER_ACCOUNT) {
if (!resource) {
@@ -203,7 +219,7 @@ export const sortUsersAlpha = (a: User, b: User) => {
export const filterScopeOptions = (
optionArray: SelectorOptions[],
user: UserData
user: UserData,
) => {
if (user.scope === USER_SP) {
return optionArray.filter((option) => option.value !== USER_ADMIN);

View File

@@ -18,7 +18,7 @@ const fuzzyMatch = (patterns: string[], items: string[]) => {
export const useFilteredResults = <Type>(
rawFilter: string,
rawCollection: Type[] | undefined
rawCollection: Type[] | undefined,
) => {
const splitFilter = useMemo(() => rawFilter.split(" "), [rawFilter]);
const filteredCollection = useMemo(() => {

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