mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2026-01-25 02:08:19 +00:00
Compare commits
158 Commits
feature/up
...
v0.9.3-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94181873f3 | ||
|
|
3437d7f3d7 | ||
|
|
38128b1531 | ||
|
|
f9e4c241f3 | ||
|
|
c4be87353c | ||
|
|
9c9699ea69 | ||
|
|
e48fce08d4 | ||
|
|
e36b031c76 | ||
|
|
bf87e4fb80 | ||
|
|
b8140ba0d6 | ||
|
|
9fd847015e | ||
|
|
c237b7e7f2 | ||
|
|
48b3e14076 | ||
|
|
db08badb9b | ||
|
|
423c8de513 | ||
|
|
668a642b09 | ||
|
|
411eb4ece8 | ||
|
|
8d4ffddddc | ||
|
|
294b7b2058 | ||
|
|
e32664d0e0 | ||
|
|
ae8b4ae124 | ||
|
|
a586771ea6 | ||
|
|
7aaea04d3c | ||
|
|
f1d2ed8abd | ||
|
|
d7d61a769d | ||
|
|
c9da7946f3 | ||
|
|
5755cd8886 | ||
|
|
786327a0b9 | ||
|
|
2c390715d8 | ||
|
|
dcdc2c0808 | ||
|
|
a3c48e7efb | ||
|
|
6b9167e6b8 | ||
|
|
e7889e1ad3 | ||
|
|
1111e93918 | ||
|
|
4df5709c10 | ||
|
|
760ddd64bb | ||
|
|
bd8612bb67 | ||
|
|
bc68eb8e71 | ||
|
|
ea2713a021 | ||
|
|
1d1909732f | ||
|
|
d3354bbe9d | ||
|
|
d95b8073d3 | ||
|
|
e8355a1dd3 | ||
|
|
8be61ddfad | ||
|
|
05c1d9efaa | ||
|
|
01a5476dfe | ||
|
|
9d2fee64e6 | ||
|
|
3a87f5f1c2 | ||
|
|
a991b56a4e | ||
|
|
6e14207327 | ||
|
|
8d8d46e76e | ||
|
|
7f72d739cd | ||
|
|
c804d60664 | ||
|
|
df3fc8f2b7 | ||
|
|
65e5b511c3 | ||
|
|
dc519bdef9 | ||
|
|
af1ba3a15c | ||
|
|
67b7792d04 | ||
|
|
c5e7eb0d23 | ||
|
|
6ddcb82adc | ||
|
|
8b9c7ca9c0 | ||
|
|
353c7cfff8 | ||
|
|
7828dc3827 | ||
|
|
213267f682 | ||
|
|
cf056ae6f1 | ||
|
|
1c16d707ca | ||
|
|
2f2e58e180 | ||
|
|
eae674b992 | ||
|
|
aa7889a0d8 | ||
|
|
a892550b06 | ||
|
|
053f8e509f | ||
|
|
fc40695828 | ||
|
|
42af4f6243 | ||
|
|
7ec8065977 | ||
|
|
d8f05da6fd | ||
|
|
15c2b955ca | ||
|
|
87b3ca7e94 | ||
|
|
adafff7ec3 | ||
|
|
bc9a2464fd | ||
|
|
2a6f8c272c | ||
|
|
f031c47228 | ||
|
|
2e9b86c0c4 | ||
|
|
dd93bedd0e | ||
|
|
e2157ce50e | ||
|
|
a382f21f86 | ||
|
|
a20e1513bc | ||
|
|
af8c09587c | ||
|
|
3a19ff6840 | ||
|
|
729cefb06c | ||
|
|
26e3856603 | ||
|
|
f5302583b5 | ||
|
|
b5c27bb096 | ||
|
|
4a2c36ebba | ||
|
|
62234f9f64 | ||
|
|
9ddafee2cc | ||
|
|
24fc9d1bff | ||
|
|
08ab494cef | ||
|
|
75e7785061 | ||
|
|
72de9178a2 | ||
|
|
9741e5601f | ||
|
|
346ac66440 | ||
|
|
843d1eda1e | ||
|
|
27f02c2bb3 | ||
|
|
bb18556a6c | ||
|
|
393dd7374f | ||
|
|
4ad2154337 | ||
|
|
08d1293e34 | ||
|
|
4eb2281b9a | ||
|
|
61bd1f9bab | ||
|
|
16629ba508 | ||
|
|
63f8a82443 | ||
|
|
9ce1d83c8f | ||
|
|
961b7ecccb | ||
|
|
3fb63c82ac | ||
|
|
cb2d5926b2 | ||
|
|
595e900468 | ||
|
|
0dd9548600 | ||
|
|
96ffce8cd1 | ||
|
|
b1fe033c12 | ||
|
|
a8d12546d9 | ||
|
|
724d86821d | ||
|
|
f91bbe9245 | ||
|
|
91625612d5 | ||
|
|
fbe71925b4 | ||
|
|
377fd40e2c | ||
|
|
af37066201 | ||
|
|
fffd86619d | ||
|
|
77c270e078 | ||
|
|
54ff53817f | ||
|
|
986b9a5eeb | ||
|
|
683693ec0e | ||
|
|
6cb1c50cf0 | ||
|
|
3d9a39ac3b | ||
|
|
4d7e84fa43 | ||
|
|
41423a443a | ||
|
|
bfbd66ef5c | ||
|
|
414bca4fdc | ||
|
|
a6c8257b60 | ||
|
|
dbb39db54e | ||
|
|
6712d8944b | ||
|
|
7051985aad | ||
|
|
88dae20666 | ||
|
|
977a08eaf7 | ||
|
|
1bfc722960 | ||
|
|
49c18ebd26 | ||
|
|
aba8b2be3a | ||
|
|
f4d7880ab7 | ||
|
|
7f93489580 | ||
|
|
19fafdc908 | ||
|
|
a165bfc4d6 | ||
|
|
e26d9b95cb | ||
|
|
e425d825bc | ||
|
|
a8d28da221 | ||
|
|
e3855e83f7 | ||
|
|
446b6e76e2 | ||
|
|
0b55cdcf85 | ||
|
|
f1743a9129 | ||
|
|
ec46121696 |
33
.env
33
.env
@@ -1,5 +1,32 @@
|
||||
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
|
||||
# VITE_APP_ENABLE_ACCOUNT_LIMITS_ALL=true
|
||||
|
||||
# disables controls for default application routing to carrier for SP and account level users
|
||||
#VITE_APP_DISABLE_DEFAULT_TRUNK_ROUTING=true
|
||||
## disables Least cost routing feature
|
||||
#VITE_APP_LCR_DISABLED=true
|
||||
## disables Jaeger Tracing feature
|
||||
#VITE_APP_JAEGER_TRACING_DISABLED=true
|
||||
## enable record All Calls feature
|
||||
#VITE_APP_DISABLE_CALL_RECORDING=true
|
||||
## enable Forgot password
|
||||
#VITE_APP_ENABLE_FORGOT_PASSWORD=true
|
||||
## enable hosted system
|
||||
#VITE_APP_ENABLE_HOSTED_SYSTEM=true
|
||||
## Google Client ID
|
||||
#VITE_APP_GOOGLE_CLIENT_ID=
|
||||
## Github Client ID
|
||||
#VITE_APP_GITHUB_CLIENT_ID=
|
||||
## Default jambonz service provider SID
|
||||
#VITE_APP_DEFAULT_SERVICE_PROVIDER_SID=
|
||||
## Base url for jambomz webapp
|
||||
#VITE_APP_BASE_URL="http://jambonz.one"
|
||||
## Strip publishable key
|
||||
#VITE_APP_STRIPE_PUBLISHABLE_KEY="pk_test_EChRaX9Tjk8csZZVSeoGqNvu00lsJzjaU1"
|
||||
## 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
|
||||
47
.github/workflows/docker-publish.yml
vendored
47
.github/workflows/docker-publish.yml
vendored
@@ -2,16 +2,8 @@ name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
# Publish `main` as Docker `latest` image.
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Publish `v1.2.3` tags as releases.
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
IMAGE_NAME: webapp
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
push:
|
||||
@@ -19,20 +11,13 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build image
|
||||
run: docker build . --file Dockerfile --tag $IMAGE_NAME
|
||||
|
||||
- name: Log into registry
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push image
|
||||
- name: prepare tag
|
||||
id: prepare_tag
|
||||
run: |
|
||||
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
|
||||
|
||||
# Change all uppercase to lowercase
|
||||
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
|
||||
IMAGE_ID=jambonz/webapp
|
||||
|
||||
# Strip git ref prefix from version
|
||||
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
|
||||
@@ -46,5 +31,21 @@ jobs:
|
||||
echo IMAGE_ID=$IMAGE_ID
|
||||
echo VERSION=$VERSION
|
||||
|
||||
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
|
||||
docker push $IMAGE_ID:$VERSION
|
||||
echo "image_id=$IMAGE_ID" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.prepare_tag.outputs.image_id }}:${{ steps.prepare_tag.outputs.version }}
|
||||
build-args: |
|
||||
GITHUB_REPOSITORY=$GITHUB_REPOSITORY
|
||||
GITHUB_REF=$GITHUB_REF
|
||||
|
||||
6
.github/workflows/pr-checks.yml
vendored
6
.github/workflows/pr-checks.yml
vendored
@@ -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') }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:18.8.0-alpine as builder
|
||||
FROM node:18.15-alpine3.16 as builder
|
||||
RUN apk update && apk add --no-cache python3 make g++
|
||||
COPY . /opt/app
|
||||
WORKDIR /opt/app/
|
||||
@@ -6,7 +6,7 @@ RUN npm install
|
||||
RUN npm run build
|
||||
RUN npm prune
|
||||
|
||||
FROM node:18.9.0-alpine as webapp
|
||||
FROM node:18.14.1-alpine as webapp
|
||||
RUN apk add curl
|
||||
WORKDIR /opt/app
|
||||
COPY . /opt/app
|
||||
|
||||
4
LICENSE
4
LICENSE
@@ -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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
@@ -44,7 +44,7 @@ declare global {
|
||||
*/
|
||||
mountTestProvider(
|
||||
component: React.ReactNode,
|
||||
options?: MountOptions & { authProps?: TestProviderProps["authProps"] }
|
||||
options?: MountOptions & { authProps?: TestProviderProps["authProps"] },
|
||||
): Cypress.Chainable<MountReturn>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -9,9 +9,16 @@ API_PORT="${API_PORT:-3000}"
|
||||
API_VERSION="${API_VERSION:-v1}"
|
||||
API_BASE_URL=${API_BASE_URL:-http://$PUBLIC_IPV4:$API_PORT/$API_VERSION}
|
||||
|
||||
# Default to "false" if not set
|
||||
DISABLE_LCR=${DISABLE_LCR:-false}
|
||||
DISABLE_JAEGER_TRACING=${DISABLE_JAEGER_TRACING:-false}
|
||||
DISABLE_CUSTOM_SPEECH=${DISABLE_CUSTOM_SPEECH:-false}
|
||||
ENABLE_FORGOT_PASSWORD=${ENABLE_FORGOT_PASSWORD:-false}
|
||||
DISABLE_CALL_RECORDING=${DISABLE_CALL_RECORDING:-false}
|
||||
|
||||
# Serialize window global to provide the API URL to static frontend dist
|
||||
# This is declared and utilized in the web app: src/api/constants.ts
|
||||
SCRIPT_TAG="<script>window.JAMBONZ = { API_BASE_URL: \"${API_BASE_URL}\" };</script>"
|
||||
SCRIPT_TAG="<script>window.JAMBONZ = {API_BASE_URL: \"${API_BASE_URL}\",DISABLE_LCR: \"${DISABLE_LCR}\",DISABLE_JAEGER_TRACING: \"${DISABLE_JAEGER_TRACING}\",DISABLE_CUSTOM_SPEECH: \"${DISABLE_CUSTOM_SPEECH}\",ENABLE_FORGOT_PASSWORD: \"${ENABLE_FORGOT_PASSWORD}\",DISABLE_CALL_RECORDING: \"${DISABLE_CALL_RECORDING}\"};</script>"
|
||||
sed -i -e "\@</head>@i\ $SCRIPT_TAG" ./dist/index.html
|
||||
|
||||
# Start the frontend web app static server
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Simple provisioning webapp for jambonz."
|
||||
content="Build innovative voice and collaboration services with jambonz, the open-source communication platform for conversational AI providers and CSPs."
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
@@ -46,7 +46,7 @@
|
||||
as="font"
|
||||
type="font/woff"
|
||||
/>
|
||||
<title>Jambonz Web App</title>
|
||||
<title>Jambonz Portal | Jambonz CPaaS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
10882
package-lock.json
generated
10882
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
61
package.json
61
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jambonz-webapp",
|
||||
"description": "A simple provisioning web app for jambonz",
|
||||
"version": "v1.0.0",
|
||||
"version": "0.9.3",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -41,39 +41,46 @@
|
||||
"deploy": "npm i && npm run build && npm run pm2"
|
||||
},
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.5",
|
||||
"@jambonz/ui-kit": "^0.0.21",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"@stripe/react-stripe-js": "^2.6.2",
|
||||
"@stripe/stripe-js": "^3.2.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-router-dom": "^6.3.0"
|
||||
"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",
|
||||
"@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",
|
||||
|
||||
@@ -31,9 +31,10 @@ app.get(
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const attempted_at = new Date(start.getTime() + i * increment);
|
||||
const failed = 0 === i % 5;
|
||||
const call_sid = nanoid();
|
||||
const call: RecentCall = {
|
||||
account_sid: req.params.account_sid,
|
||||
call_sid: nanoid(),
|
||||
call_sid,
|
||||
from: "15083084809",
|
||||
to: "18882349999",
|
||||
answered: !failed,
|
||||
@@ -48,6 +49,8 @@ app.get(
|
||||
remote_host: "3.55.24.34",
|
||||
direction: 0 === i % 2 ? "inbound" : "outbound",
|
||||
trunk: 0 === i % 2 ? "twilio" : "user",
|
||||
trace_id: nanoid(),
|
||||
recording_url: `http://127.0.0.1:3002/api/Accounts/${req.params.account_sid}/RecentCalls/${call_sid}/record`,
|
||||
};
|
||||
data.push(call);
|
||||
}
|
||||
@@ -108,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(
|
||||
@@ -123,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
|
||||
@@ -133,7 +136,36 @@ app.get(
|
||||
"Content-Disposition": "attachment",
|
||||
})
|
||||
.send(pcap); // server: Buffer => client: Blob
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/Accounts/:account_sid/RecentCalls/:call_sid/record",
|
||||
(req: Request, res: Response) => {
|
||||
/** Sample pcap file from: https://wiki.wireshark.org/SampleCaptures#sip-and-rtp */
|
||||
const wav: Buffer = fs.readFileSync(
|
||||
path.resolve(process.cwd(), "server", "example.mp3"),
|
||||
);
|
||||
|
||||
res
|
||||
.status(200)
|
||||
.set({
|
||||
"Content-Type": "audio/wav",
|
||||
"Content-Disposition": "attachment",
|
||||
})
|
||||
.send(wav); // server: Buffer => client: Blob
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/Accounts/:account_sid/RecentCalls/trace/:trace_id",
|
||||
(req: Request, res: Response) => {
|
||||
const json = fs.readFileSync(
|
||||
path.resolve(process.cwd(), "server", "sample-jaeger.json"),
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
res.status(200).json(JSON.parse(json));
|
||||
},
|
||||
);
|
||||
|
||||
/** Alerts mock API responses for local dev */
|
||||
|
||||
BIN
server/example.mp3
Normal file
BIN
server/example.mp3
Normal file
Binary file not shown.
4588
server/sample-jaeger.json
Normal file
4588
server/sample-jaeger.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,38 @@
|
||||
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` */
|
||||
interface JambonzWindowObject {
|
||||
API_BASE_URL: string;
|
||||
DISABLE_LCR: string;
|
||||
DISABLE_JAEGER_TRACING: string;
|
||||
DISABLE_CUSTOM_SPEECH: string;
|
||||
ENABLE_FORGOT_PASSWORD: string;
|
||||
ENABLE_HOSTED_SYSTEM: string;
|
||||
DISABLE_CALL_RECORDING: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
BASE_URL: string;
|
||||
DEFAULT_SERVICE_PROVIDER_SID: string;
|
||||
STRIPE_PUBLISHABLE_KEY: string;
|
||||
DISABLE_ADDITIONAL_SPEECH_VENDORS: string;
|
||||
AWS_REGION: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -22,12 +42,72 @@ 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;
|
||||
|
||||
/** Disable custom speech vendor*/
|
||||
export const DISABLE_CUSTOM_SPEECH: boolean =
|
||||
window.JAMBONZ?.DISABLE_CUSTOM_SPEECH === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_DISABLE_CUSTOM_SPEECH || "false");
|
||||
|
||||
/** Enable Forgot Password */
|
||||
export const ENABLE_FORGOT_PASSWORD: boolean =
|
||||
window.JAMBONZ?.ENABLE_FORGOT_PASSWORD === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_APP_ENABLE_FORGOT_PASSWORD || "false");
|
||||
|
||||
/** Enable Cloud version */
|
||||
export const ENABLE_HOSTED_SYSTEM: boolean =
|
||||
window.JAMBONZ?.ENABLE_HOSTED_SYSTEM === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_APP_ENABLE_HOSTED_SYSTEM || "false");
|
||||
/** Disable Lcr */
|
||||
export const DISABLE_LCR: boolean =
|
||||
window.JAMBONZ?.DISABLE_LCR === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_APP_LCR_DISABLED || "false");
|
||||
|
||||
/** Disable jaeger tracing */
|
||||
export const DISABLE_JAEGER_TRACING: boolean =
|
||||
window.JAMBONZ?.DISABLE_JAEGER_TRACING === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_APP_JAEGER_TRACING_DISABLED || "false");
|
||||
|
||||
/** Enable Record All Call Feature */
|
||||
export const DISABLE_CALL_RECORDING: boolean =
|
||||
window.JAMBONZ?.DISABLE_CALL_RECORDING === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_APP_DISABLE_CALL_RECORDING || "false");
|
||||
|
||||
/** 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;
|
||||
|
||||
export const GITHUB_CLIENT_ID: string =
|
||||
window.JAMBONZ?.GITHUB_CLIENT_ID || import.meta.env.VITE_APP_GITHUB_CLIENT_ID;
|
||||
|
||||
export const BASE_URL: string =
|
||||
window.JAMBONZ?.BASE_URL || import.meta.env.VITE_APP_BASE_URL;
|
||||
|
||||
export const GOOGLE_CLIENT_ID: string =
|
||||
window.JAMBONZ?.GOOGLE_CLIENT_ID || import.meta.env.VITE_APP_GOOGLE_CLIENT_ID;
|
||||
|
||||
export const STRIPE_PUBLISHABLE_KEY: string =
|
||||
window.JAMBONZ?.STRIPE_PUBLISHABLE_KEY ||
|
||||
import.meta.env.VITE_APP_STRIPE_PUBLISHABLE_KEY;
|
||||
|
||||
/** TCP Max Port */
|
||||
export const TCP_MAX_PORT = 65535;
|
||||
|
||||
@@ -54,7 +134,7 @@ export const DEFAULT_SIP_GATEWAY: SipGateway = {
|
||||
ipv4: "",
|
||||
port: 5060,
|
||||
netmask: 32,
|
||||
is_active: false,
|
||||
is_active: true,
|
||||
inbound: 1,
|
||||
outbound: 0,
|
||||
};
|
||||
@@ -69,7 +149,6 @@ export const DEFAULT_SMPP_GATEWAY: SmppGateway = {
|
||||
inbound: 1,
|
||||
outbound: 1,
|
||||
};
|
||||
|
||||
/** Netmask Bits */
|
||||
export const NETMASK_BITS = Array(32)
|
||||
.fill(0)
|
||||
@@ -81,6 +160,139 @@ export const NETMASK_OPTIONS = NETMASK_BITS.map((bit) => ({
|
||||
value: bit.toString(),
|
||||
}));
|
||||
|
||||
/** SIP Gateway Protocol */
|
||||
export const SIP_GATEWAY_PROTOCOL_OPTIONS = [
|
||||
{
|
||||
name: "UDP",
|
||||
value: "udp",
|
||||
},
|
||||
{
|
||||
name: "TCP",
|
||||
value: "tcp",
|
||||
},
|
||||
{
|
||||
name: "TLS",
|
||||
value: "tls",
|
||||
},
|
||||
{
|
||||
name: "TLS/SRTP",
|
||||
value: "tls/srtp",
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Record bucket type
|
||||
*/
|
||||
export const BUCKET_VENDOR_AWS = "aws_s3";
|
||||
export const BUCKET_VENDOR_S3_COMPATIBLE = "s3_compatible";
|
||||
export const BUCKET_VENDOR_GOOGLE = "google";
|
||||
export const BUCKET_VENDOR_AZURE = "azure";
|
||||
export const BUCKET_VENDOR_OPTIONS = [
|
||||
{
|
||||
name: "NONE",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
name: "AWS S3",
|
||||
value: BUCKET_VENDOR_AWS,
|
||||
},
|
||||
{
|
||||
name: "AWS S3 Compatible",
|
||||
value: BUCKET_VENDOR_S3_COMPATIBLE,
|
||||
},
|
||||
{
|
||||
name: "Azure Cloud Storage",
|
||||
value: BUCKET_VENDOR_AZURE,
|
||||
},
|
||||
{
|
||||
name: "Google Cloud Storage",
|
||||
value: BUCKET_VENDOR_GOOGLE,
|
||||
},
|
||||
];
|
||||
|
||||
export const AUDIO_FORMAT_OPTIONS = [
|
||||
{
|
||||
name: "mp3",
|
||||
value: "mp3",
|
||||
},
|
||||
{
|
||||
name: "wav",
|
||||
value: "wav",
|
||||
},
|
||||
];
|
||||
|
||||
export const 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;
|
||||
@@ -94,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" },
|
||||
@@ -112,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[] = [
|
||||
{
|
||||
@@ -172,6 +390,16 @@ export const DEFAULT_PSWD_SETTINGS: PasswordSettings = {
|
||||
require_special_character: 0,
|
||||
};
|
||||
|
||||
export const PlanType = {
|
||||
PAID: "paid",
|
||||
TRIAL: "trial",
|
||||
FREE: "free",
|
||||
};
|
||||
|
||||
export const CurrencySymbol: Currency = {
|
||||
usd: "$",
|
||||
};
|
||||
|
||||
/** User scope values values */
|
||||
export const USER_ADMIN = "admin";
|
||||
export const USER_SP = "service_provider";
|
||||
@@ -182,8 +410,16 @@ export const CRED_OK = "ok";
|
||||
export const CRED_FAIL = "fail";
|
||||
export const CRED_NOT_TESTED = "not tested";
|
||||
|
||||
/** Voip Carrier Register result status values */
|
||||
export const CARRIER_REG_OK = "ok";
|
||||
export const CARRIER_REG_FAIL = "fail";
|
||||
|
||||
export const PRIVACY_POLICY = "https://jambonz.org/privacy";
|
||||
export const TERMS_OF_SERVICE = "https://jambonz.org/terms";
|
||||
|
||||
/** API base paths */
|
||||
export const API_LOGIN = `${API_BASE_URL}/login`;
|
||||
export const API_LOGOUT = `${API_BASE_URL}/logout`;
|
||||
export const API_SBCS = `${API_BASE_URL}/Sbcs`;
|
||||
export const API_USERS = `${API_BASE_URL}/Users`;
|
||||
export const API_API_KEYS = `${API_BASE_URL}/ApiKeys`;
|
||||
@@ -196,3 +432,18 @@ export const API_CARRIERS = `${API_BASE_URL}/VoipCarriers`;
|
||||
export const API_SMPP_GATEWAY = `${API_BASE_URL}/SmppGateways`;
|
||||
export const API_SIP_GATEWAY = `${API_BASE_URL}/SipGateways`;
|
||||
export const API_PASSWORD_SETTINGS = `${API_BASE_URL}/PasswordSettings`;
|
||||
export const API_FORGOT_PASSWORD = `${API_BASE_URL}/forgot-password`;
|
||||
export const API_SYSTEM_INFORMATION = `${API_BASE_URL}/SystemInformation`;
|
||||
export const API_LCRS = `${API_BASE_URL}/Lcrs`;
|
||||
export const API_LCR_ROUTES = `${API_BASE_URL}/LcrRoutes`;
|
||||
export const API_LCR_CARRIER_SET_ENTRIES = `${API_BASE_URL}/LcrCarrierSetEntries`;
|
||||
export const API_TTS_CACHE = `${API_BASE_URL}/TtsCache`;
|
||||
export const API_CLIENTS = `${API_BASE_URL}/Clients`;
|
||||
export const API_REGISTER = `${API_BASE_URL}/register`;
|
||||
export const API_ACTIVATION_CODE = `${API_BASE_URL}/ActivationCode`;
|
||||
export const API_AVAILABILITY = `${API_BASE_URL}/Availability`;
|
||||
export const API_PRICE = `${API_BASE_URL}/Prices`;
|
||||
export const API_SUBSCRIPTIONS = `${API_BASE_URL}/Subscriptions`;
|
||||
export const API_CHANGE_PASSWORD = `${API_BASE_URL}/change-password`;
|
||||
export const API_SIGNIN = `${API_BASE_URL}/signin`;
|
||||
export const API_GOOGLE_CUSTOM_VOICES = `${API_BASE_URL}/GoogleCustomVoices`;
|
||||
|
||||
452
src/api/index.ts
452
src/api/index.ts
@@ -17,7 +17,23 @@ import {
|
||||
API_SMPP_GATEWAY,
|
||||
API_SIP_GATEWAY,
|
||||
API_PASSWORD_SETTINGS,
|
||||
API_FORGOT_PASSWORD,
|
||||
USER_ACCOUNT,
|
||||
API_LOGOUT,
|
||||
API_SYSTEM_INFORMATION,
|
||||
API_LCR_ROUTES,
|
||||
API_LCR_CARRIER_SET_ENTRIES,
|
||||
API_LCRS,
|
||||
API_TTS_CACHE,
|
||||
API_CLIENTS,
|
||||
API_REGISTER,
|
||||
API_ACTIVATION_CODE,
|
||||
API_AVAILABILITY,
|
||||
API_PRICE,
|
||||
API_SUBSCRIPTIONS,
|
||||
API_CHANGE_PASSWORD,
|
||||
API_SIGNIN,
|
||||
API_GOOGLE_CUSTOM_VOICES,
|
||||
} from "./constants";
|
||||
import { ROUTE_LOGIN } from "src/router/routes";
|
||||
import {
|
||||
@@ -58,18 +74,40 @@ import type {
|
||||
Limit,
|
||||
LimitCategories,
|
||||
PasswordSettings,
|
||||
ForgotPassword,
|
||||
SystemInformation,
|
||||
Lcr,
|
||||
LcrRoute,
|
||||
LcrCarrierSetEntry,
|
||||
BucketCredential,
|
||||
BucketCredentialTestResult,
|
||||
Client,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
ActivationCode,
|
||||
CurrentUserData,
|
||||
PriceInfo,
|
||||
Subscription,
|
||||
DeleteAccount,
|
||||
ChangePassword,
|
||||
SignIn,
|
||||
GoogleCustomVoice,
|
||||
GoogleCustomVoicesQuery,
|
||||
SpeechSupportedLanguagesAndVoices,
|
||||
} from "./types";
|
||||
import { StatusCodes } from "./types";
|
||||
import { Availability, StatusCodes } from "./types";
|
||||
import { JaegerRoot } from "./jaeger-types";
|
||||
|
||||
/** Wrap all requests to normalize response handling */
|
||||
const fetchTransport = <Type>(
|
||||
url: string,
|
||||
options: RequestInit
|
||||
options: RequestInit,
|
||||
): Promise<FetchTransport<Type>> => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const transport: FetchTransport<Type> = {
|
||||
headers: response.headers,
|
||||
status: response.status,
|
||||
json: <Type>{},
|
||||
};
|
||||
@@ -145,13 +183,13 @@ const getAuthHeaders = () => {
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
};
|
||||
};
|
||||
|
||||
const getQuery = <Type>(query: Type) => {
|
||||
return decodeURIComponent(
|
||||
new URLSearchParams(query as unknown as Record<string, string>).toString()
|
||||
new URLSearchParams(query as unknown as Record<string, string>).toString(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -187,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) => {
|
||||
@@ -197,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",
|
||||
@@ -221,6 +269,17 @@ export const deleteFetch = <Type>(url: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteFetchWithPayload = <Type, Payload>(
|
||||
url: string,
|
||||
payload: Payload,
|
||||
) => {
|
||||
return fetchTransport<Type>(url, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
};
|
||||
|
||||
/** All APIs need a wrapper utility that uses the FetchTransport */
|
||||
|
||||
export const postLogin = (payload: UserLoginPayload) => {
|
||||
@@ -233,12 +292,16 @@ export const postLogin = (payload: UserLoginPayload) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const postLogout = () => {
|
||||
return postFetch<undefined>(API_LOGOUT);
|
||||
};
|
||||
|
||||
/** Named wrappers for `postFetch` */
|
||||
|
||||
export const postServiceProviders = (payload: Partial<ServiceProvider>) => {
|
||||
return postFetch<SidResponse, Partial<ServiceProvider>>(
|
||||
API_SERVICE_PROVIDERS,
|
||||
payload
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -250,16 +313,26 @@ export const postAccount = (payload: Partial<Account>) => {
|
||||
return postFetch<SidResponse, Partial<Account>>(API_ACCOUNTS, payload);
|
||||
};
|
||||
|
||||
export const postAccountBucketCredentialTest = (
|
||||
sid: string,
|
||||
payload: Partial<BucketCredential>,
|
||||
) => {
|
||||
return postFetch<BucketCredentialTestResult, Partial<BucketCredential>>(
|
||||
`${API_ACCOUNTS}/${sid}/BucketCredentialTest`,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
export const postApplication = (payload: Partial<Application>) => {
|
||||
return postFetch<SidResponse, Partial<Application>>(
|
||||
API_APPLICATIONS,
|
||||
payload
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
export const postSpeechService = (
|
||||
sid: string,
|
||||
payload: Partial<SpeechCredential>
|
||||
payload: Partial<SpeechCredential>,
|
||||
) => {
|
||||
const userData = parseJwt(getToken());
|
||||
const apiUrl =
|
||||
@@ -273,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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -296,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}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -319,31 +392,123 @@ 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,
|
||||
);
|
||||
};
|
||||
|
||||
export const postSystemInformation = (payload: Partial<SystemInformation>) => {
|
||||
return postFetch<SystemInformation, Partial<SystemInformation>>(
|
||||
API_SYSTEM_INFORMATION,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
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>,
|
||||
) => {
|
||||
return postFetch<SidResponse, Partial<LcrCarrierSetEntry>>(
|
||||
API_LCR_CARRIER_SET_ENTRIES,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
export const postClient = (payload: Partial<Client>) => {
|
||||
return postFetch<SidResponse, Partial<Client>>(API_CLIENTS, payload);
|
||||
};
|
||||
|
||||
export const postRegister = (payload: Partial<RegisterRequest>) => {
|
||||
return postFetch<RegisterResponse, Partial<RegisterRequest>>(
|
||||
API_REGISTER,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
export const postSipRealms = (accountSid: string, domain: string) => {
|
||||
return postFetch<EmptyResponse>(
|
||||
`${API_ACCOUNTS}/${accountSid}/SipRealms/${domain}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const postSubscriptions = (payload: Partial<Subscription>) => {
|
||||
return postFetch<Subscription, Partial<Subscription>>(
|
||||
API_SUBSCRIPTIONS,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
export const postChangepassword = (payload: Partial<ChangePassword>) => {
|
||||
return postFetch<EmptyResponse, Partial<ChangePassword>>(
|
||||
API_CHANGE_PASSWORD,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
export const postSignIn = (payload: Partial<SignIn>) => {
|
||||
return postFetch<SignIn, Partial<SignIn>>(API_SIGNIN, payload);
|
||||
};
|
||||
|
||||
export const postGoogleCustomVoice = (payload: Partial<GoogleCustomVoice>) => {
|
||||
return postFetch<SidResponse, Partial<GoogleCustomVoice>>(
|
||||
API_GOOGLE_CUSTOM_VOICES,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
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` */
|
||||
@@ -351,38 +516,38 @@ export const postPasswordSettings = (payload: Partial<PasswordSettings>) => {
|
||||
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 =
|
||||
@@ -395,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 =
|
||||
@@ -427,14 +592,72 @@ 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,
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
export const putLcrCarrierSetEntries = (
|
||||
sid: string,
|
||||
payload: Partial<LcrCarrierSetEntry>,
|
||||
) => {
|
||||
return putFetch<EmptyResponse, Partial<LcrCarrierSetEntry>>(
|
||||
`${API_LCR_CARRIER_SET_ENTRIES}/${sid}`,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
export const putClient = (sid: string, payload: Partial<Client>) => {
|
||||
return putFetch<EmptyResponse, Partial<Client>>(
|
||||
`${API_CLIENTS}/${sid}`,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
export const putActivationCode = (
|
||||
code: string,
|
||||
payload: Partial<ActivationCode>,
|
||||
) => {
|
||||
return putFetch<EmptyResponse, Partial<ActivationCode>>(
|
||||
`${API_ACTIVATION_CODE}/${code}`,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
export const putGoogleCustomVoice = (
|
||||
sid: string,
|
||||
payload: Partial<GoogleCustomVoice>,
|
||||
) => {
|
||||
return putFetch<EmptyResponse, Partial<GoogleCustomVoice>>(
|
||||
`${API_GOOGLE_CUSTOM_VOICES}/${sid}`,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -452,8 +675,11 @@ export const deleteApiKey = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_API_KEYS}/${sid}`);
|
||||
};
|
||||
|
||||
export const deleteAccount = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_ACCOUNTS}/${sid}`);
|
||||
export const deleteAccount = (sid: string, payload: Partial<DeleteAccount>) => {
|
||||
return deleteFetchWithPayload<EmptyResponse, Partial<DeleteAccount>>(
|
||||
`${API_ACCOUNTS}/${sid}`,
|
||||
payload,
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteApplication = (sid: string) => {
|
||||
@@ -462,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}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -488,19 +714,46 @@ 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}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteLcr = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_LCRS}/${sid}`);
|
||||
};
|
||||
|
||||
export const deleteLcrRoute = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_LCR_ROUTES}/${sid}`);
|
||||
};
|
||||
|
||||
export const deleteTtsCache = () => {
|
||||
return deleteFetch<EmptyResponse>(API_TTS_CACHE);
|
||||
};
|
||||
|
||||
export const deleteAccountTtsCache = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_BASE_URL}/Accounts/${sid}/TtsCache`);
|
||||
};
|
||||
|
||||
export const deleteClient = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_CLIENTS}/${sid}`);
|
||||
};
|
||||
|
||||
export const deleteRecord = (url: string) => {
|
||||
return deleteFetch<EmptyResponse>(url);
|
||||
};
|
||||
|
||||
export const deleteGoogleCustomVoice = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_GOOGLE_CUSTOM_VOICES}/${sid}`);
|
||||
};
|
||||
/** Named wrappers for `getFetch` */
|
||||
|
||||
export const getUser = (sid: string) => {
|
||||
@@ -513,35 +766,115 @@ export const getServiceProviders = () => {
|
||||
|
||||
export const getAccountWebhook = (sid: string) => {
|
||||
return getFetch<SecretResponse>(
|
||||
`${API_ACCOUNTS}/${sid}/WebhookSecret?regenerate=true`
|
||||
`${API_ACCOUNTS}/${sid}/WebhookSecret?regenerate=true`,
|
||||
);
|
||||
};
|
||||
|
||||
export const getLcrs = () => {
|
||||
return getFetch<Lcr[]>(API_LCRS);
|
||||
};
|
||||
|
||||
export const getLcr = (sid: string) => {
|
||||
return getFetch<Lcr>(`${API_LCRS}/${sid}`);
|
||||
};
|
||||
|
||||
export const getLcrRoutes = (sid: string) => {
|
||||
return getFetch<LcrRoute[]>(`${API_LCR_ROUTES}?lcr_sid=${sid}`);
|
||||
};
|
||||
|
||||
export const getLcrRoute = (sid: string) => {
|
||||
return getFetch<LcrRoute>(`${API_LCR_ROUTES}/${sid}`);
|
||||
};
|
||||
|
||||
export const getLcrCarrierSetEtries = (sid: string) => {
|
||||
return getFetch<LcrCarrierSetEntry[]>(
|
||||
`${API_LCR_CARRIER_SET_ENTRIES}?lcr_route_sid=${sid}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const getClients = () => {
|
||||
return getFetch<Client[]>(API_CLIENTS);
|
||||
};
|
||||
|
||||
export const getClient = (sid: string) => {
|
||||
return getFetch<Client[]>(`${API_CLIENTS}/${sid}`);
|
||||
};
|
||||
|
||||
export const getAvailability = (domain: string) => {
|
||||
return getFetch<Availability>(
|
||||
`${API_AVAILABILITY}?type=subdomain&value=${domain}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const getGoogleCustomVoices = (
|
||||
query: Partial<GoogleCustomVoicesQuery>,
|
||||
) => {
|
||||
const qryStr = getQuery<Partial<GoogleCustomVoicesQuery>>(query);
|
||||
return getFetch<GoogleCustomVoice[]>(`${API_GOOGLE_CUSTOM_VOICES}?${qryStr}`);
|
||||
};
|
||||
|
||||
/** Wrappers for APIs that can have a mock dev server response */
|
||||
|
||||
export const getMe = () => {
|
||||
return getFetch<CurrentUserData>(`${API_USERS}/me`);
|
||||
};
|
||||
|
||||
export const getRecentCalls = (sid: string, query: Partial<CallQuery>) => {
|
||||
const qryStr = getQuery<Partial<CallQuery>>(query);
|
||||
|
||||
return getFetch<PagedResponse<RecentCall>>(
|
||||
import.meta.env.DEV
|
||||
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls?${qryStr}`
|
||||
: `${API_ACCOUNTS}/${sid}/RecentCalls?${qryStr}`
|
||||
`${API_ACCOUNTS}/${sid}/RecentCalls?${qryStr}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const getRecentCall = (sid: string, sipCallId: string) => {
|
||||
return getFetch<TotalResponse>(
|
||||
import.meta.env.DEV
|
||||
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}`
|
||||
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}`
|
||||
`${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const getPcap = (sid: string, sipCallId: string) => {
|
||||
export const getRecentCallLog = (sid: string, callSid: string) => {
|
||||
return getFetch<string[]>(
|
||||
`${API_ACCOUNTS}/${sid}/RecentCalls/${callSid}/logs`,
|
||||
);
|
||||
};
|
||||
|
||||
export const getPcap = (sid: string, sipCallId: string, method: string) => {
|
||||
return getBlob(
|
||||
import.meta.env.DEV
|
||||
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}/pcap`
|
||||
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/pcap`
|
||||
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
|
||||
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`,
|
||||
);
|
||||
};
|
||||
|
||||
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}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const getServiceProviderRecentCall = (
|
||||
sid: string,
|
||||
sipCallId: string,
|
||||
) => {
|
||||
return getFetch<TotalResponse>(
|
||||
import.meta.env.DEV
|
||||
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}`
|
||||
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const getServiceProviderPcap = (
|
||||
sid: string,
|
||||
sipCallId: 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`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -551,10 +884,31 @@ export const getAlerts = (sid: string, query: Partial<PageQuery>) => {
|
||||
return getFetch<PagedResponse<Alert>>(
|
||||
import.meta.env.DEV
|
||||
? `${DEV_BASE_URL}/Accounts/${sid}/Alerts?${qryStr}`
|
||||
: `${API_ACCOUNTS}/${sid}/Alerts?${qryStr}`
|
||||
: `${API_ACCOUNTS}/${sid}/Alerts?${qryStr}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const getPrice = () => {
|
||||
return getFetch<PriceInfo[]>(API_PRICE);
|
||||
};
|
||||
|
||||
export const getSpeechSupportedLanguagesAndVoices = (
|
||||
sid: string | undefined,
|
||||
vendor: string,
|
||||
label: string,
|
||||
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 */
|
||||
@@ -614,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) {
|
||||
|
||||
87
src/api/jaeger-types.ts
Normal file
87
src/api/jaeger-types.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
export interface JaegerRoot {
|
||||
resourceSpans: JaegerResourceSpan[];
|
||||
}
|
||||
|
||||
export interface JaegerResourceSpan {
|
||||
resource: JaegerResource;
|
||||
instrumentationLibrarySpans: InstrumentationLibrarySpan[];
|
||||
}
|
||||
|
||||
export interface JaegerResource {
|
||||
attributes: JaegerAttribute[];
|
||||
}
|
||||
|
||||
export interface InstrumentationLibrarySpan {
|
||||
instrumentationLibrary: InstrumentationLibrary;
|
||||
spans: JaegerSpan[];
|
||||
}
|
||||
|
||||
export interface InstrumentationLibrary {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface JaegerSpan {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
startTimeUnixNano: number;
|
||||
endTimeUnixNano: number;
|
||||
attributes: JaegerAttribute[];
|
||||
}
|
||||
|
||||
export interface JaegerAttribute {
|
||||
key: string;
|
||||
value: JaegerValue;
|
||||
}
|
||||
|
||||
export interface WaveSurferSttResult {
|
||||
vendor: string;
|
||||
transcript: string;
|
||||
confidence: number;
|
||||
language_code: string;
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
export interface WaveSurferTtsLatencyResult {
|
||||
vendor: string;
|
||||
latency: string;
|
||||
isCached: string;
|
||||
}
|
||||
|
||||
export interface WaveSurferGatherSpeechVerbHookLatencyResult {
|
||||
statusCode: number;
|
||||
latency: string;
|
||||
}
|
||||
|
||||
export interface WaveSurferDtmfResult {
|
||||
dtmf: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export interface JaegerValue {
|
||||
stringValue: string;
|
||||
doubleValue: string;
|
||||
boolValue: string;
|
||||
}
|
||||
|
||||
export interface JaegerGroup {
|
||||
level: number;
|
||||
startPx: number;
|
||||
endPx: number;
|
||||
durationPx: number;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
durationMs: number;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
startTimeUnixNano: number;
|
||||
endTimeUnixNano: number;
|
||||
attributes: JaegerAttribute[];
|
||||
children: JaegerGroup[];
|
||||
}
|
||||
386
src/api/types.ts
386
src/api/types.ts
@@ -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"
|
||||
@@ -51,6 +53,7 @@ export enum StatusCodes {
|
||||
/** Fetch transport interfaces */
|
||||
|
||||
export interface FetchTransport<Type> {
|
||||
headers: Headers;
|
||||
status: StatusCodes;
|
||||
json: Type;
|
||||
blob?: Blob;
|
||||
@@ -62,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 */
|
||||
@@ -87,7 +88,7 @@ export interface SelectorOptions {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Pcap {
|
||||
export interface DownloadedBlob {
|
||||
data_url: string;
|
||||
file_name: string;
|
||||
}
|
||||
@@ -102,6 +103,11 @@ export interface CredentialTestResult {
|
||||
tts: CredentialTest;
|
||||
}
|
||||
|
||||
export interface BucketCredentialTestResult {
|
||||
status: CredentialStatus;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface LimitField {
|
||||
label: string;
|
||||
category: LimitCategories;
|
||||
@@ -113,6 +119,22 @@ export interface PasswordSettings {
|
||||
require_special_character: number;
|
||||
}
|
||||
|
||||
export interface ForgotPassword {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface SystemInformation {
|
||||
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 {
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** API responses/payloads */
|
||||
|
||||
export interface User {
|
||||
@@ -121,6 +143,7 @@ export interface User {
|
||||
name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_view_only: boolean;
|
||||
force_change: boolean;
|
||||
account_sid: string | null;
|
||||
account_name?: string | null;
|
||||
@@ -128,6 +151,7 @@ export interface User {
|
||||
service_provider_name?: string | null;
|
||||
initial_password?: string;
|
||||
permissions?: UserPermissions[];
|
||||
provider?: null | string;
|
||||
}
|
||||
|
||||
export interface UserLogin {
|
||||
@@ -151,6 +175,7 @@ export interface UserUpdatePayload {
|
||||
name: string;
|
||||
force_change: boolean;
|
||||
is_active: boolean;
|
||||
is_view_only: boolean;
|
||||
service_provider_sid: string | null;
|
||||
account_sid: string | null;
|
||||
}
|
||||
@@ -166,12 +191,15 @@ export interface UserJWT {
|
||||
|
||||
export interface CurrentUserData {
|
||||
user: User;
|
||||
account?: Account;
|
||||
subscription?: null | Subscription;
|
||||
}
|
||||
|
||||
export interface ServiceProvider {
|
||||
name: string;
|
||||
ms_teams_fqdn: null | string;
|
||||
service_provider_sid: string;
|
||||
lcr_sid: null | string;
|
||||
}
|
||||
|
||||
export interface Limit {
|
||||
@@ -224,6 +252,7 @@ export interface Smpp {
|
||||
export interface Account {
|
||||
name: string;
|
||||
sip_realm: null | string;
|
||||
root_domain?: null | string;
|
||||
account_sid: string;
|
||||
webhook_secret: string;
|
||||
siprec_hook_sid: null | string;
|
||||
@@ -231,6 +260,59 @@ export interface Account {
|
||||
registration_hook: null | WebHook;
|
||||
service_provider_sid: string;
|
||||
device_calling_application_sid: null | string;
|
||||
record_all_calls: number;
|
||||
record_format?: null | string;
|
||||
bucket_credential: null | BucketCredential;
|
||||
plan_type?: string;
|
||||
device_to_call_ratio?: number;
|
||||
trial_end_date?: null | string;
|
||||
is_active: boolean;
|
||||
enable_debug_log: boolean;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
price_id?: null | string;
|
||||
product_sid?: null | string;
|
||||
name?: string;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
action?: null | string;
|
||||
payment_method_id?: null | string;
|
||||
account_subscription_sid?: null | string;
|
||||
stripe_customer_id?: null | string;
|
||||
products?: null | Product[];
|
||||
start_date?: string;
|
||||
status?: string;
|
||||
client_secret?: null | string;
|
||||
last4?: null | string;
|
||||
exp_month?: null | string;
|
||||
exp_year?: null | string;
|
||||
card_type?: null | string;
|
||||
reason?: null | string;
|
||||
dry_run?: boolean;
|
||||
currency?: null | string;
|
||||
prorated_cost?: number;
|
||||
monthly_cost?: number;
|
||||
next_invoice_date?: null | string;
|
||||
}
|
||||
|
||||
export interface AwsTag {
|
||||
Key: string;
|
||||
Value: string;
|
||||
}
|
||||
|
||||
export interface BucketCredential {
|
||||
vendor: null | string;
|
||||
region?: null | string;
|
||||
name?: null | string;
|
||||
access_key_id?: null | string;
|
||||
secret_access_key?: null | string;
|
||||
tags?: null | AwsTag[];
|
||||
service_key?: null | string;
|
||||
connection_string?: null | string;
|
||||
endpoint?: null | string;
|
||||
}
|
||||
|
||||
export interface Application {
|
||||
@@ -238,14 +320,24 @@ 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;
|
||||
speech_synthesis_vendor: null | Lowercase<Vendor>;
|
||||
speech_synthesis_language: null | string;
|
||||
speech_synthesis_label: null | string;
|
||||
speech_recognizer_vendor: null | Lowercase<Vendor>;
|
||||
speech_recognizer_language: null | string;
|
||||
speech_recognizer_label: null | string;
|
||||
record_all_calls: number;
|
||||
use_for_fallback_speech: number;
|
||||
fallback_speech_synthesis_vendor: null | string;
|
||||
fallback_speech_synthesis_language: null | string;
|
||||
fallback_speech_synthesis_voice: null | string;
|
||||
fallback_speech_synthesis_label: null | string;
|
||||
fallback_speech_recognizer_vendor: null | string;
|
||||
fallback_speech_recognizer_language: null | string;
|
||||
fallback_speech_recognizer_label: null | string;
|
||||
}
|
||||
|
||||
export interface PhoneNumber {
|
||||
@@ -281,6 +373,19 @@ export interface RecentCall {
|
||||
remote_host: string;
|
||||
direction: string;
|
||||
trunk: string;
|
||||
trace_id: string;
|
||||
recording_url?: string;
|
||||
}
|
||||
|
||||
export interface GoogleCustomVoice {
|
||||
google_custom_voice_sid?: string;
|
||||
speech_credential_sid?: string;
|
||||
name: string;
|
||||
reported_usage: string;
|
||||
model?: string;
|
||||
use_voice_cloning_key: number;
|
||||
voice_cloning_key?: string | null;
|
||||
voice_cloning_key_file?: File | null;
|
||||
}
|
||||
|
||||
export interface SpeechCredential {
|
||||
@@ -294,21 +399,43 @@ export interface SpeechCredential {
|
||||
region: null | string;
|
||||
aws_region: null | string;
|
||||
api_key: null | string;
|
||||
role_arn: null | string;
|
||||
user_id: null | string;
|
||||
access_key_id: null | string;
|
||||
secret_access_key: null | string;
|
||||
service_key: null | string;
|
||||
use_custom_tts: number;
|
||||
custom_tts_endpoint_url: null | string;
|
||||
custom_tts_endpoint: null | string;
|
||||
use_custom_stt: number;
|
||||
custom_stt_endpoint_url: null | string;
|
||||
custom_stt_endpoint: null | string;
|
||||
client_id: null | string;
|
||||
client_secret: null | string;
|
||||
secret: null | string;
|
||||
nuance_tts_uri: null | string;
|
||||
nuance_stt_uri: null | string;
|
||||
tts_api_key: null | string;
|
||||
tts_region: null | string;
|
||||
stt_api_key: null | string;
|
||||
stt_region: null | string;
|
||||
instance_id: null | string;
|
||||
riva_server_uri: null | string;
|
||||
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 {
|
||||
@@ -319,6 +446,15 @@ export interface Alert {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface CarrierRegisterStatus {
|
||||
status: null | string;
|
||||
reason: null | string;
|
||||
cseq: null | string;
|
||||
callId: null | string;
|
||||
}
|
||||
|
||||
export type DtmfType = "rfc2833" | "tones" | "info";
|
||||
|
||||
export interface Carrier {
|
||||
voip_carrier_sid: string;
|
||||
name: string;
|
||||
@@ -344,6 +480,9 @@ export interface Carrier {
|
||||
smpp_inbound_system_id: null | string;
|
||||
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 {
|
||||
@@ -354,7 +493,6 @@ export interface PredefinedCarrier extends Carrier {
|
||||
export interface Gateway {
|
||||
voip_carrier_sid: string;
|
||||
ipv4: string;
|
||||
port: number;
|
||||
netmask: number;
|
||||
inbound: number;
|
||||
outbound: number;
|
||||
@@ -363,12 +501,56 @@ export interface Gateway {
|
||||
export interface SipGateway extends Gateway {
|
||||
sip_gateway_sid?: null | string;
|
||||
is_active: boolean;
|
||||
protocol?: string;
|
||||
port: number | null;
|
||||
pad_crypto?: boolean;
|
||||
send_options_ping?: boolean;
|
||||
use_sips_scheme?: boolean;
|
||||
}
|
||||
|
||||
export interface SmppGateway extends Gateway {
|
||||
smpp_gateway_sid?: null | string;
|
||||
is_primary: boolean;
|
||||
use_tls: boolean;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface Lcr {
|
||||
lcr_sid?: null | string;
|
||||
is_active: boolean;
|
||||
name: null | string;
|
||||
default_carrier_set_entry_sid?: null | string;
|
||||
account_sid: null | string;
|
||||
service_provider_sid: null | string;
|
||||
number_routes?: number;
|
||||
}
|
||||
|
||||
export interface LcrRoute {
|
||||
lcr_route_sid?: null | string;
|
||||
lcr_sid: null | string;
|
||||
regex: null | string;
|
||||
description?: null | string;
|
||||
priority: number;
|
||||
lcr_carrier_set_entries?: LcrCarrierSetEntry[];
|
||||
}
|
||||
|
||||
export interface LcrCarrierSetEntry {
|
||||
lcr_carrier_set_entry_sid?: null | string;
|
||||
workload?: number;
|
||||
lcr_route_sid: null | string;
|
||||
voip_carrier_sid: null | string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
client_sid?: null | string;
|
||||
account_sid: null | string;
|
||||
username: null | string;
|
||||
password?: null | string;
|
||||
is_active: boolean;
|
||||
allow_direct_app_calling: boolean;
|
||||
allow_direct_queue_calling: boolean;
|
||||
allow_direct_user_calling: boolean;
|
||||
}
|
||||
|
||||
export interface PageQuery {
|
||||
@@ -383,6 +565,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;
|
||||
@@ -413,3 +602,182 @@ export interface EmptyResponse {
|
||||
export interface TotalResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
service_provider_sid: string;
|
||||
provider: string;
|
||||
oauth2_code?: string;
|
||||
oauth2_state?: string;
|
||||
oauth2_client_id?: string;
|
||||
oauth2_redirect_uri?: string;
|
||||
locationBeforeAuth?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
email_activation_code?: string;
|
||||
inviteCode?: string;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
jwt: string;
|
||||
user_sid: string;
|
||||
account_sid: string;
|
||||
root_domain: string;
|
||||
}
|
||||
|
||||
export interface ActivationCode {
|
||||
user_sid: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Availability {
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
total: number;
|
||||
currency: null | string;
|
||||
next_payment_attempt: null | string;
|
||||
}
|
||||
|
||||
export type Currency = {
|
||||
[key: string]: null | string;
|
||||
};
|
||||
|
||||
export interface Recurring {
|
||||
aggregate_usage: null | string;
|
||||
interval: null | string;
|
||||
interval_count: number;
|
||||
trial_period_days: null | string;
|
||||
usage_type: string;
|
||||
}
|
||||
|
||||
export interface Price {
|
||||
billing_scheme: string;
|
||||
currency: string;
|
||||
recurring: Recurring;
|
||||
stripe_price_id: null | string;
|
||||
tiers_mode: null | string;
|
||||
type: null | string;
|
||||
unit_amount: number;
|
||||
unit_amount_decimal: null | string;
|
||||
}
|
||||
|
||||
export interface PriceInfo {
|
||||
category: null | string;
|
||||
description: null | string;
|
||||
name: null | string;
|
||||
prices: Price[];
|
||||
product_sid: null | string;
|
||||
stripe_product_id: null | string;
|
||||
unit_label: null | string;
|
||||
}
|
||||
|
||||
export interface StripeCustomerId {
|
||||
stripe_customer_id: null | string;
|
||||
}
|
||||
|
||||
export interface Tier {
|
||||
up_to: number;
|
||||
flat_amount: number;
|
||||
unit_amount: number;
|
||||
}
|
||||
|
||||
export interface ServiceData {
|
||||
category: null | string;
|
||||
name: null | string;
|
||||
service: null | string;
|
||||
fees: number;
|
||||
feesLabel: null | string;
|
||||
cost: number;
|
||||
capacity: number;
|
||||
invalid: boolean;
|
||||
currency: null | string;
|
||||
min: number;
|
||||
max: number;
|
||||
dirty: boolean;
|
||||
visible: boolean;
|
||||
required: boolean;
|
||||
billing_scheme?: null | string;
|
||||
stripe_price_id?: null | string;
|
||||
unit_label?: null | string;
|
||||
product_sid?: null | string;
|
||||
stripe_product_id?: null | string;
|
||||
tiers?: Tier[];
|
||||
}
|
||||
|
||||
export interface DeleteAccount {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ChangePassword {
|
||||
old_password: null | string;
|
||||
new_password: null | string;
|
||||
}
|
||||
|
||||
export interface SignIn {
|
||||
link?: null | string;
|
||||
jwt?: null | string;
|
||||
account_sid?: null | string;
|
||||
}
|
||||
|
||||
export interface GetLanguagesAndVoices {
|
||||
vendor: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SpeechSupportedLanguagesAndVoices {
|
||||
tts: VoiceLanguage[];
|
||||
stt: Language[];
|
||||
models: Model[];
|
||||
sttModels: 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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 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>
|
||||
{label && <label htmlFor="account_filter">{label}:</label>}
|
||||
<TypeaheadSelector
|
||||
id="account_filter"
|
||||
name="account_filter"
|
||||
value={accountSid}
|
||||
options={options}
|
||||
className="small"
|
||||
onChange={(e) => {
|
||||
setAccountSid(e.target.value);
|
||||
setAccountFilter(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
</>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
48
src/components/domain-input/index.tsx
Normal file
48
src/components/domain-input/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { Icons } from "../icons";
|
||||
import "./styles.scss";
|
||||
|
||||
type DomainInputProbs = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value: string;
|
||||
setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
root_domain: string;
|
||||
placeholder?: string;
|
||||
is_valid: boolean;
|
||||
};
|
||||
|
||||
export const DomainInput = ({
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
setValue,
|
||||
root_domain,
|
||||
is_valid,
|
||||
placeholder,
|
||||
}: DomainInputProbs) => {
|
||||
return (
|
||||
<>
|
||||
<div className="clipboard clipboard-domain">
|
||||
<div className="input-container">
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
<div className={`input-icon txt--${is_valid ? "teal" : "red"}`}>
|
||||
{is_valid ? <Icons.CheckCircle /> : <Icons.XCircle />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="root-domain">
|
||||
<p>{root_domain}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DomainInput;
|
||||
55
src/components/domain-input/styles.scss
Normal file
55
src/components/domain-input/styles.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
@use "../../styles/vars";
|
||||
@use "../../styles/mixins";
|
||||
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
|
||||
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
|
||||
|
||||
.input-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clipboard-domain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
width: 100%;
|
||||
height: vars.$clipheight;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.internal form & {
|
||||
max-width: calc(#{vars.$widthinput} - #{vars.$clipheight});
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
right: 5%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.root-domain {
|
||||
height: vars.$clipheight;
|
||||
border-bottom-right-radius: ui-vars.$px01;
|
||||
border-top-right-radius: ui-vars.$px01;
|
||||
border: 2px solid ui-vars.$grey;
|
||||
border-left: 0;
|
||||
background-color: ui-vars.$pink;
|
||||
padding: ui-vars.$px01;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&[disabled] {
|
||||
@include mixins.disabled();
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/components/editboard/index.tsx
Normal file
39
src/components/editboard/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icons } from "src/components/icons";
|
||||
|
||||
type EditBoardProps = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
text: string;
|
||||
path: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export const EditBoard = ({
|
||||
text,
|
||||
id = "",
|
||||
name = "",
|
||||
path,
|
||||
title,
|
||||
}: EditBoardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const handleClick = () => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="clipboard inpbtn">
|
||||
<input id={id} name={name} type="text" readOnly value={text} />
|
||||
<button
|
||||
className="btnty"
|
||||
type="button"
|
||||
title={title ? title : "Edit"}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icons.Edit />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -7,9 +7,11 @@ type CheckzoneProps = {
|
||||
id?: string;
|
||||
name: string;
|
||||
label: string;
|
||||
labelNode?: React.ReactNode;
|
||||
hidden?: boolean;
|
||||
children: React.ReactNode;
|
||||
initialCheck: boolean;
|
||||
disabled?: boolean;
|
||||
handleChecked?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
@@ -22,12 +24,14 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
labelNode,
|
||||
hidden = false,
|
||||
children,
|
||||
initialCheck,
|
||||
handleChecked,
|
||||
disabled = false,
|
||||
}: CheckzoneProps,
|
||||
ref
|
||||
ref,
|
||||
) => {
|
||||
const [checked, setChecked] = useState(false);
|
||||
const classesTop = classNames({
|
||||
@@ -47,26 +51,30 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
|
||||
return (
|
||||
<div className={classesTop}>
|
||||
<label>
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
id={id || name}
|
||||
onChange={(e) => {
|
||||
setChecked(e.target.checked);
|
||||
<div className="label-container">
|
||||
<input
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
id={id || name}
|
||||
onChange={(e) => {
|
||||
setChecked(e.target.checked);
|
||||
|
||||
if (handleChecked) {
|
||||
handleChecked(e);
|
||||
}
|
||||
}}
|
||||
checked={checked}
|
||||
/>
|
||||
<div>{label}</div>
|
||||
if (handleChecked) {
|
||||
handleChecked(e);
|
||||
}
|
||||
}}
|
||||
checked={checked}
|
||||
/>
|
||||
{label && <div>{label}</div>}
|
||||
{labelNode && labelNode}
|
||||
</div>
|
||||
</label>
|
||||
<div className={classesIn}>{children}</div>
|
||||
{checked && <div className={classesIn}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Checkzone.displayName = "Checkzone";
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
width: 100%;
|
||||
max-width: vars.$widthinput;
|
||||
|
||||
> label {
|
||||
.label-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
> label {
|
||||
input {
|
||||
margin-top: ui-vars.$px01;
|
||||
margin-right: ui-vars.$px02;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +38,10 @@
|
||||
margin-top: ui-vars.$px01;
|
||||
}
|
||||
|
||||
> a {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.active {
|
||||
cursor: auto;
|
||||
opacity: 1;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -8,6 +8,8 @@ type PasswdProps = JSX.IntrinsicElements["input"] & {
|
||||
locked?: boolean;
|
||||
/** This is optional in case an onChange override is necessary... */
|
||||
setValue?: React.Dispatch<React.SetStateAction<string>>;
|
||||
/** Whether to ignore password managers */
|
||||
ignorePasswordManager?: boolean;
|
||||
};
|
||||
|
||||
type PasswdRef = HTMLInputElement;
|
||||
@@ -22,16 +24,27 @@ export const Passwd = forwardRef<PasswdRef, PasswdProps>(
|
||||
setValue,
|
||||
placeholder,
|
||||
locked = false,
|
||||
ignorePasswordManager = true,
|
||||
...restProps
|
||||
}: PasswdProps,
|
||||
ref
|
||||
ref,
|
||||
) => {
|
||||
const [reveal, setReveal] = useState(false);
|
||||
|
||||
// Create object with conditional password manager attributes
|
||||
const passwordManagerAttributes = ignorePasswordManager
|
||||
? {
|
||||
"data-lpignore": "true",
|
||||
"data-1p-ignore": "",
|
||||
"data-form-type": "other",
|
||||
"data-bwignore": "",
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div className="passwd">
|
||||
<input
|
||||
autoComplete={"off"}
|
||||
autoComplete="off"
|
||||
ref={ref}
|
||||
type={reveal ? "text" : "password"}
|
||||
name={name}
|
||||
@@ -43,6 +56,7 @@ export const Passwd = forwardRef<PasswdRef, PasswdProps>(
|
||||
}
|
||||
}}
|
||||
{...restProps}
|
||||
{...passwordManagerAttributes}
|
||||
/>
|
||||
{!locked && (
|
||||
<button
|
||||
@@ -55,7 +69,7 @@ export const Passwd = forwardRef<PasswdRef, PasswdProps>(
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Passwd.displayName = "Passwd";
|
||||
|
||||
@@ -20,7 +20,7 @@ type SelectorRef = HTMLSelectElement;
|
||||
export const Selector = forwardRef<SelectorRef, SelectorProps>(
|
||||
(
|
||||
{ id, name, value, options, disabled, ...restProps }: SelectorProps,
|
||||
ref
|
||||
ref,
|
||||
) => {
|
||||
const [focus, setFocus] = useState(false);
|
||||
const classes = {
|
||||
@@ -42,7 +42,7 @@ export const Selector = forwardRef<SelectorRef, SelectorProps>(
|
||||
{...restProps}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
<option key={`${id}_${option.value}`} value={option.value}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
@@ -53,7 +53,7 @@ export const Selector = forwardRef<SelectorRef, SelectorProps>(
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Selector.displayName = "Selector";
|
||||
|
||||
391
src/components/forms/typeahead-selector/index.tsx
Normal file
391
src/components/forms/typeahead-selector/index.tsx
Normal 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";
|
||||
182
src/components/forms/typeahead-selector/styles.scss
Normal file
182
src/components/forms/typeahead-selector/styles.scss
Normal 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;
|
||||
}
|
||||
@@ -39,6 +39,18 @@ import {
|
||||
PhoneOutgoing,
|
||||
PhoneIncoming,
|
||||
MoreHorizontal,
|
||||
Share2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Play,
|
||||
Pause,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Download,
|
||||
Smartphone,
|
||||
Youtube,
|
||||
Mail,
|
||||
Tag,
|
||||
} from "react-feather";
|
||||
|
||||
import type { Icon } from "react-feather";
|
||||
@@ -88,4 +100,16 @@ export const Icons: IconMap = {
|
||||
PhoneOutgoing,
|
||||
PhoneIncoming,
|
||||
MoreHorizontal,
|
||||
Share2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Play,
|
||||
Pause,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Download,
|
||||
Smartphone,
|
||||
Youtube,
|
||||
Mail,
|
||||
Tag,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom";
|
||||
import { Button, ButtonGroup } from "@jambonz/ui-kit";
|
||||
|
||||
import "./styles.scss";
|
||||
import { Spinner } from "../spinner";
|
||||
|
||||
type ModalProps = {
|
||||
disabled?: boolean;
|
||||
@@ -49,7 +50,7 @@ export const Modal = ({
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>,
|
||||
portal
|
||||
portal,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,6 +70,7 @@ export const ModalForm = ({
|
||||
}}
|
||||
>
|
||||
<div className="modal__stuff">{children}</div>
|
||||
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
small
|
||||
@@ -85,15 +87,25 @@ export const ModalForm = ({
|
||||
</ButtonGroup>
|
||||
</form>
|
||||
</div>,
|
||||
portal
|
||||
portal,
|
||||
);
|
||||
};
|
||||
|
||||
export const ModalClose = ({ children, handleClose }: CloseProps) => {
|
||||
return ReactDOM.createPortal(
|
||||
<div className="modal">
|
||||
<div className="modal__box">
|
||||
<div className="modal__stuff">{children}</div>
|
||||
<div className="modal" role="presentation" onClick={handleClose}>
|
||||
<div
|
||||
className="modal__box"
|
||||
role="presentation"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="modal__stuff"
|
||||
role="presentation"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<ButtonGroup right>
|
||||
<Button type="button" small subStyle="grey" onClick={handleClose}>
|
||||
Close
|
||||
@@ -101,6 +113,40 @@ export const ModalClose = ({ children, handleClose }: CloseProps) => {
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>,
|
||||
portal
|
||||
portal,
|
||||
);
|
||||
};
|
||||
|
||||
type LoaderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ModalLoader = ({ children }: LoaderProps) => {
|
||||
return ReactDOM.createPortal(
|
||||
<div className="modal" role="presentation">
|
||||
<div
|
||||
className="modal__box"
|
||||
role="presentation"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="modal__stuff"
|
||||
role="presentation"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
portal,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
import { classNames } from "@jambonz/ui-kit";
|
||||
|
||||
import { Icons } from "src/components/icons";
|
||||
@@ -7,14 +7,18 @@ import "./styles.scss";
|
||||
|
||||
type SearchFilterProps = JSX.IntrinsicElements["input"] & {
|
||||
filter: [string, React.Dispatch<React.SetStateAction<string>>];
|
||||
delay?: number | null;
|
||||
};
|
||||
|
||||
export const SearchFilter = ({
|
||||
placeholder,
|
||||
filter: [filterValue, setFilterValue],
|
||||
delay,
|
||||
}: SearchFilterProps) => {
|
||||
const [focus, setFocus] = useState(false);
|
||||
const [tmpFilterValue, setTmpFilterValue] = useState(filterValue);
|
||||
const [appearance, setAppearance] = useState(false);
|
||||
const typingTimeoutRef = useRef<number | null>(null);
|
||||
const classes = {
|
||||
"search-filter": true,
|
||||
focused: focus,
|
||||
@@ -23,7 +27,18 @@ export const SearchFilter = ({
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFilterValue(e.target.value.toLowerCase());
|
||||
setTmpFilterValue(e.target.value.toLowerCase());
|
||||
if (delay) {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
setFilterValue(e.target.value.toLowerCase());
|
||||
}, delay);
|
||||
} else {
|
||||
setFilterValue(e.target.value.toLowerCase());
|
||||
}
|
||||
|
||||
if (e.target.value) {
|
||||
setAppearance(true);
|
||||
@@ -31,7 +46,7 @@ export const SearchFilter = ({
|
||||
setAppearance(false);
|
||||
}
|
||||
},
|
||||
[setFilterValue]
|
||||
[setFilterValue],
|
||||
);
|
||||
|
||||
const handleActive = useCallback(() => {
|
||||
@@ -51,7 +66,7 @@ export const SearchFilter = ({
|
||||
type="search"
|
||||
name="search_filter"
|
||||
placeholder={placeholder}
|
||||
value={filterValue}
|
||||
value={tmpFilterValue}
|
||||
onChange={handleChange}
|
||||
onFocus={() => {
|
||||
setFocus(true);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -23,6 +23,6 @@ export const Toast = ({ type, message }: ToastProps) => {
|
||||
{message}
|
||||
</div>
|
||||
</div>,
|
||||
portal
|
||||
portal,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,14 +9,15 @@ import "./styles.scss";
|
||||
type TooltipProps = {
|
||||
text: IMessage;
|
||||
children: React.ReactNode;
|
||||
subStyle?: string;
|
||||
};
|
||||
|
||||
export const Tooltip = ({ text, children }: TooltipProps) => {
|
||||
export const Tooltip = ({ text, children, subStyle }: TooltipProps) => {
|
||||
return (
|
||||
<div className="tooltip">
|
||||
<div className="tooltip__reveal">{text}</div>
|
||||
{children}
|
||||
<Icons.HelpCircle />
|
||||
{subStyle === "info" ? <Icons.Info /> : <Icons.HelpCircle />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
export const TOAST_TIME = 3000;
|
||||
export const TOAST_TIME = 5000;
|
||||
export const SESS_FLASH_MSG = "SESS_FLASH_MSG";
|
||||
export const SESS_USER_SID = "SESS_USER_SID";
|
||||
export const SESS_OLD_PASSWORD = "SESS_OLD_PASSWORD";
|
||||
@@ -13,16 +13,6 @@ export const MSG_PASSWD_MATCH = "Passwords do not match";
|
||||
export const MSG_SERVER_DOWN = "The server cannot be reached";
|
||||
export const MSG_LOGGED_OUT = "You've successfully logged out.";
|
||||
export const MSG_MUST_LOGIN = "You must log in to view that page";
|
||||
export const MSG_PASSWD_CRITERIA = (
|
||||
<>
|
||||
Password must:
|
||||
<ul>
|
||||
<li>Be at least 6 characters</li>
|
||||
<li>Contain at least one letter</li>
|
||||
<li>Contain at least one number</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
export const MSG_REQUIRED_FIELDS = (
|
||||
<>
|
||||
Fields marked with an asterisk<span>*</span> are required.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { classNames, M, Icon, Button } from "@jambonz/ui-kit";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icons, ModalForm } from "src/components";
|
||||
import { naviTop, naviByo } from "./items";
|
||||
@@ -20,6 +20,8 @@ import "./styles.scss";
|
||||
import { ScopedAccess } from "src/components/scoped-access";
|
||||
import { Scope, UserData } from "src/store/types";
|
||||
import { USER_ADMIN } from "src/api/constants";
|
||||
import { ROUTE_LOGIN } from "src/router/routes";
|
||||
import { Lcr } from "src/api/types";
|
||||
|
||||
type CommonProps = {
|
||||
handleMenu: () => void;
|
||||
@@ -34,16 +36,17 @@ type NaviProps = CommonProps & {
|
||||
type ItemProps = CommonProps & {
|
||||
item: NaviItem;
|
||||
user?: UserData;
|
||||
lcr?: Lcr;
|
||||
};
|
||||
|
||||
const Item = ({ item, user, handleMenu }: ItemProps) => {
|
||||
const Item = ({ item, user, lcr, handleMenu }: ItemProps) => {
|
||||
const location = useLocation();
|
||||
const active = location.pathname.includes(item.route(user));
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
to={item.route(user)}
|
||||
to={item.route(user, lcr)}
|
||||
className={classNames({ navi__link: true, "txt--jean": true, active })}
|
||||
onClick={handleMenu}
|
||||
>
|
||||
@@ -61,7 +64,9 @@ export const Navi = ({
|
||||
handleLogout,
|
||||
}: NaviProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const user = useSelectState("user");
|
||||
const lcr = useSelectState("lcr");
|
||||
const accessControl = useSelectState("accessControl");
|
||||
const serviceProviders = useSelectState("serviceProviders");
|
||||
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||
@@ -71,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]);
|
||||
|
||||
@@ -95,7 +100,7 @@ export const Navi = ({
|
||||
toastSuccess(
|
||||
<>
|
||||
Added new service provider <strong>{name}</strong>
|
||||
</>
|
||||
</>,
|
||||
);
|
||||
dispatch({ type: "serviceProviders" });
|
||||
setSid(json.sid);
|
||||
@@ -118,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) {
|
||||
@@ -131,6 +136,7 @@ export const Navi = ({
|
||||
useEffect(() => {
|
||||
dispatch({ type: "user" });
|
||||
dispatch({ type: "serviceProviders" });
|
||||
dispatch({ type: "lcr" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -160,6 +166,7 @@ export const Navi = ({
|
||||
onChange={(e) => {
|
||||
setSid(e.target.value);
|
||||
setActiveSP(e.target.value);
|
||||
navigate(ROUTE_LOGIN);
|
||||
}}
|
||||
disabled={user?.scope !== USER_ADMIN}
|
||||
>
|
||||
@@ -218,6 +225,7 @@ export const Navi = ({
|
||||
<Item
|
||||
key={item.label}
|
||||
user={user}
|
||||
lcr={lcr}
|
||||
item={item}
|
||||
handleMenu={handleMenu}
|
||||
/>
|
||||
|
||||
@@ -9,28 +9,41 @@ import {
|
||||
ROUTE_INTERNAL_SPEECH,
|
||||
ROUTE_INTERNAL_PHONE_NUMBERS,
|
||||
ROUTE_INTERNAL_MS_TEAMS_TENANTS,
|
||||
ROUTE_INTERNAL_LEST_COST_ROUTING,
|
||||
ROUTE_INTERNAL_CLIENTS,
|
||||
} from "src/router/routes";
|
||||
import { Icons } from "src/components";
|
||||
import { Scope, UserData } from "src/store/types";
|
||||
|
||||
import type { Icon } from "react-feather";
|
||||
import type { ACL } from "src/store/types";
|
||||
import { Lcr } from "src/api/types";
|
||||
import {
|
||||
DISABLE_LCR,
|
||||
ENABLE_HOSTED_SYSTEM as ENABLE_HOSTED_SYSTEM,
|
||||
} from "src/api/constants";
|
||||
|
||||
export interface NaviItem {
|
||||
label: string;
|
||||
icon: Icon;
|
||||
route: (user?: UserData) => string;
|
||||
route: (user?: UserData, lcr?: Lcr) => string;
|
||||
acl?: keyof ACL;
|
||||
scope?: Scope;
|
||||
restrict?: boolean;
|
||||
}
|
||||
|
||||
export const naviTop: NaviItem[] = [
|
||||
{
|
||||
label: "Users",
|
||||
icon: Icons.UserCheck,
|
||||
route: () => ROUTE_INTERNAL_USERS,
|
||||
},
|
||||
// User is not allowed in hosted app
|
||||
...(!ENABLE_HOSTED_SYSTEM
|
||||
? [
|
||||
{
|
||||
label: "Users",
|
||||
icon: Icons.UserCheck,
|
||||
route: () => ROUTE_INTERNAL_USERS,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
label: "Settings",
|
||||
icon: Icons.Settings,
|
||||
@@ -50,6 +63,11 @@ export const naviTop: NaviItem[] = [
|
||||
scope: Scope.account,
|
||||
restrict: true,
|
||||
},
|
||||
{
|
||||
label: "Clients",
|
||||
icon: Icons.Smartphone,
|
||||
route: () => ROUTE_INTERNAL_CLIENTS,
|
||||
},
|
||||
{
|
||||
label: "Applications",
|
||||
icon: Icons.Grid,
|
||||
@@ -89,4 +107,21 @@ export const naviByo: NaviItem[] = [
|
||||
route: () => ROUTE_INTERNAL_MS_TEAMS_TENANTS,
|
||||
acl: "hasMSTeamsFqdn",
|
||||
},
|
||||
...(DISABLE_LCR === false
|
||||
? [
|
||||
{
|
||||
label: "Outbound Call Routing",
|
||||
icon: Icons.Share2,
|
||||
route: (user, lcr) => {
|
||||
if (user?.access === Scope.admin) {
|
||||
return ROUTE_INTERNAL_LEST_COST_ROUTING;
|
||||
}
|
||||
if (lcr && lcr.lcr_sid) {
|
||||
return `${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`;
|
||||
}
|
||||
return `${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`;
|
||||
},
|
||||
} as NaviItem,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import React, { useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icons } from "src/components";
|
||||
import { ROUTE_INTERNAL_USERS } from "src/router/routes";
|
||||
import {
|
||||
ROUTE_INTERNAL_USERS,
|
||||
ROUTE_REGISTER_SUB_DOMAIN,
|
||||
} from "src/router/routes";
|
||||
import { useApiData } from "src/api";
|
||||
import { useSelectState } from "src/store";
|
||||
|
||||
import type { CurrentUserData } from "src/api/types";
|
||||
|
||||
import "./styles.scss";
|
||||
import { ENABLE_HOSTED_SYSTEM } from "src/api/constants";
|
||||
import { setRootDomain } from "src/store/localStore";
|
||||
|
||||
export const UserMe = () => {
|
||||
const user = useSelectState("user");
|
||||
const [userData] = useApiData<CurrentUserData>("Users/me");
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// If hosted platform is enabled, the account should have sip realm
|
||||
if (ENABLE_HOSTED_SYSTEM && userData && !userData.account?.sip_realm) {
|
||||
setRootDomain(userData?.account?.root_domain || "");
|
||||
navigate(ROUTE_REGISTER_SUB_DOMAIN);
|
||||
}
|
||||
}, [userData]);
|
||||
|
||||
return (
|
||||
<div className="user">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
100
src/containers/internal/views/accounts/edit-sip-realm.tsx
Normal file
100
src/containers/internal/views/accounts/edit-sip-realm.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Button, ButtonGroup, H1, MS } from "@jambonz/ui-kit";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { getAvailability, postSipRealms, useApiData } from "src/api";
|
||||
import { CurrentUserData } from "src/api/types";
|
||||
import { Section } from "src/components";
|
||||
import DomainInput from "src/components/domain-input";
|
||||
import { Message } from "src/components/forms";
|
||||
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
||||
import { hasValue } from "src/utils";
|
||||
|
||||
export const EditSipRealm = () => {
|
||||
const [name, setName] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const [userData] = useApiData<CurrentUserData>("Users/me");
|
||||
const typingTimeoutRef = useRef<number | null>(null);
|
||||
const [isValidDomain, setIsValidDomain] = useState(false);
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const rootDomain = userData?.account?.root_domain;
|
||||
const account_sid = userData?.account?.account_sid;
|
||||
|
||||
postSipRealms(account_sid || "", `${name}.${rootDomain}`)
|
||||
.then(() => {
|
||||
navigate(`${ROUTE_INTERNAL_ACCOUNTS}/${account_sid}/edit`);
|
||||
})
|
||||
.catch((error) => {
|
||||
setErrorMessage(error.msg);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
if (!name || name.length < 3) {
|
||||
setIsValidDomain(false);
|
||||
return;
|
||||
}
|
||||
setIsValidDomain(false);
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
getAvailability(`${name}.${userData?.account?.root_domain}`)
|
||||
.then(({ json }) =>
|
||||
setIsValidDomain(
|
||||
Boolean(json.available) && hasValue(name) && name.length != 0,
|
||||
),
|
||||
)
|
||||
.catch((error) => {
|
||||
setErrorMessage(error.msg);
|
||||
setIsValidDomain(false);
|
||||
});
|
||||
}, 500);
|
||||
}, [name]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Edit Sip Realm</H1>
|
||||
<Section slim>
|
||||
<form className="form form--internal" onSubmit={handleSubmit}>
|
||||
<fieldset>
|
||||
<MS>
|
||||
This is the domain name where your carrier will send calls, and
|
||||
where you can register devices to.
|
||||
</MS>
|
||||
{errorMessage && <Message message={errorMessage} />}
|
||||
<br />
|
||||
<DomainInput
|
||||
id="sip_realm"
|
||||
name="sip_realm"
|
||||
value={name}
|
||||
setValue={setName}
|
||||
placeholder="Your name here"
|
||||
root_domain={`.${userData?.account?.root_domain || ""}`}
|
||||
is_valid={isValidDomain}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<ButtonGroup left>
|
||||
<Button
|
||||
small
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" small disabled={!isValidDomain}>
|
||||
Change Sip Realm
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditSipRealm;
|
||||
@@ -7,7 +7,7 @@ import { useApiData } from "src/api";
|
||||
import { toastError, useSelectState } from "src/store";
|
||||
import { AccountForm } from "./form";
|
||||
|
||||
import type { Account, Application, Limit } from "src/api/types";
|
||||
import type { Account, Application, Limit, TtsCache } from "src/api/types";
|
||||
import {
|
||||
ROUTE_INTERNAL_ACCOUNTS,
|
||||
ROUTE_INTERNAL_APPLICATIONS,
|
||||
@@ -19,12 +19,15 @@ export const EditAccount = () => {
|
||||
const params = useParams();
|
||||
const user = useSelectState("user");
|
||||
const [data, refetch, error] = useApiData<Account>(
|
||||
`Accounts/${params.account_sid}`
|
||||
`Accounts/${params.account_sid}`,
|
||||
);
|
||||
const [limitsData, refetchLimits] = useApiData<Limit[]>(
|
||||
`Accounts/${params.account_sid}/Limits`
|
||||
`Accounts/${params.account_sid}/Limits`,
|
||||
);
|
||||
const [apps] = useApiData<Application[]>("Applications");
|
||||
const [ttsCache, ttsCacheFetcher] = useApiData<TtsCache>(
|
||||
`Accounts/${params.account_sid}/TtsCache`,
|
||||
);
|
||||
|
||||
useScopedRedirect(
|
||||
Scope.account,
|
||||
@@ -33,7 +36,7 @@ export const EditAccount = () => {
|
||||
: ROUTE_INTERNAL_APPLICATIONS,
|
||||
user,
|
||||
"You do not have access to this resource",
|
||||
data
|
||||
data,
|
||||
);
|
||||
|
||||
/** Handle error toast at top level... */
|
||||
@@ -50,6 +53,7 @@ export const EditAccount = () => {
|
||||
apps={apps}
|
||||
account={{ data, refetch, error }}
|
||||
limits={{ data: limitsData, refetch: refetchLimits }}
|
||||
ttsCache={{ data: ttsCache, refetch: ttsCacheFetcher }}
|
||||
/>
|
||||
<ApiKeys
|
||||
path={`Accounts/${params.account_sid}/ApiKeys`}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@ export const Accounts = () => {
|
||||
Scope.service_provider,
|
||||
`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/edit`,
|
||||
user,
|
||||
"You do not have permissions to manage all accounts"
|
||||
"You do not have permissions to manage all accounts",
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
@@ -40,19 +40,19 @@ export const Accounts = () => {
|
||||
user.account_sid !== account.account_sid
|
||||
) {
|
||||
toastError(
|
||||
"You do not have permissions to make changes to this Account"
|
||||
"You do not have permissions to make changes to this Account",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteAccount(account.account_sid)
|
||||
deleteAccount(account.account_sid, {})
|
||||
.then(() => {
|
||||
refetch();
|
||||
setAccount(null);
|
||||
toastSuccess(
|
||||
<>
|
||||
Deleted account <strong>{account.name}</strong>
|
||||
</>
|
||||
</>,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -71,7 +71,7 @@ export const Accounts = () => {
|
||||
</Icon>
|
||||
</Link>
|
||||
</section>
|
||||
<section className="filters filters--spaced">
|
||||
<section className="filters filters--multi">
|
||||
<SearchFilter
|
||||
placeholder="Filter accounts"
|
||||
filter={[filter, setFilter]}
|
||||
|
||||
191
src/containers/internal/views/accounts/manage-payment-form.tsx
Normal file
191
src/containers/internal/views/accounts/manage-payment-form.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Button, ButtonGroup, H1, P } from "@jambonz/ui-kit";
|
||||
import {
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import React, { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { postSubscriptions, useApiData } from "src/api";
|
||||
import { CurrentUserData, Subscription } from "src/api/types";
|
||||
import { Section } from "src/components";
|
||||
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||
import { PaymentMethod } from "@stripe/stripe-js";
|
||||
import { ModalLoader } from "src/components/modal";
|
||||
|
||||
export const ManagePaymentForm = () => {
|
||||
const user = useSelectState("user");
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [userData] = useApiData<CurrentUserData>("Users/me");
|
||||
const [isChangePayment, setIsChangePayment] = useState(false);
|
||||
const [isSavingNewCard, setIsSavingNewCard] = useState(false);
|
||||
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const createSubscription = async (paymentMethod: PaymentMethod) => {
|
||||
const body: Subscription = {
|
||||
action: "update-payment-method",
|
||||
payment_method_id: paymentMethod.id,
|
||||
};
|
||||
|
||||
postSubscriptions(body)
|
||||
.then(({ json }) => {
|
||||
if (json.status === "success") {
|
||||
toastSuccess("Payment completed successfully");
|
||||
navigate(
|
||||
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
|
||||
);
|
||||
} else if (json.status === "action required") {
|
||||
if (stripe) {
|
||||
const location = window.location;
|
||||
stripe
|
||||
.confirmPayment({
|
||||
clientSecret: json.client_secret || "",
|
||||
confirmParams: {
|
||||
return_url: `${location.protocol}//${location.host}${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
|
||||
},
|
||||
})
|
||||
.then((error) => {
|
||||
if (error) {
|
||||
toastError(error.error.message || "");
|
||||
return;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSavingNewCard(false);
|
||||
setIsShowModalLoader(false);
|
||||
});
|
||||
}
|
||||
} else if (json.status === "card error") {
|
||||
setIsSavingNewCard(false);
|
||||
setIsShowModalLoader(false);
|
||||
toastError(json.reason || "Something went wrong, please try again.");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg || "Something went wrong, please try again.");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSavingNewCard(false);
|
||||
setIsShowModalLoader(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveNewCard = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = elements.getElement(PaymentElement);
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
const { error: elementsError } = await elements.submit();
|
||||
if (elementsError) {
|
||||
toastError(elementsError.message || "");
|
||||
return;
|
||||
}
|
||||
const { error, paymentMethod } = await stripe.createPaymentMethod({
|
||||
element: card,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toastError(error.message || "Something went wrong, please try again.");
|
||||
return;
|
||||
}
|
||||
setIsSavingNewCard(true);
|
||||
setIsShowModalLoader(true);
|
||||
createSubscription(paymentMethod);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Manage Payment Information</H1>
|
||||
{userData?.subscription && (
|
||||
<Section>
|
||||
<H1 className="h3">Current Payment Information</H1>
|
||||
<div className="item__details">
|
||||
<div className="pre-grid-white">
|
||||
<div>Card Type:</div>
|
||||
<div>{userData.subscription.card_type}</div>
|
||||
<div>Card Number:</div>
|
||||
<div>
|
||||
{userData.subscription.last4
|
||||
? `**** **** **** ${userData.subscription.last4}`
|
||||
: ""}
|
||||
</div>
|
||||
<div>Expiration:</div>
|
||||
<div>
|
||||
{userData.subscription.exp_year
|
||||
? `${userData.subscription.exp_month}/${userData.subscription.exp_year}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
type="button"
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/edit`}
|
||||
small
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => setIsChangePayment(true)} small>
|
||||
Change Payment Info
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Section>
|
||||
)}
|
||||
{isChangePayment && (
|
||||
<Section>
|
||||
<div className="grid--col4--users">
|
||||
<H1 className="h3">New Payment Information</H1>
|
||||
<div className="grid__row">
|
||||
<div></div>
|
||||
<div>
|
||||
<PaymentElement
|
||||
options={{
|
||||
paymentMethodOrder: ["card"],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
type="button"
|
||||
subStyle="grey"
|
||||
onClick={() => setIsChangePayment(false)}
|
||||
small
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveNewCard}
|
||||
disabled={!stripe || isSavingNewCard}
|
||||
small
|
||||
>
|
||||
Save New Card
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Section>
|
||||
)}
|
||||
{isShowModalLoader && (
|
||||
<ModalLoader>
|
||||
<P>
|
||||
Your requested changes are being processed. Please do not leave the
|
||||
page or hit the back button until complete.
|
||||
</P>
|
||||
</ModalLoader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagePaymentForm;
|
||||
23
src/containers/internal/views/accounts/manage-payment.tsx
Normal file
23
src/containers/internal/views/accounts/manage-payment.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Elements } from "@stripe/react-stripe-js";
|
||||
import { stripePromise } from "./subscription";
|
||||
import ManagePaymentForm from "./manage-payment-form";
|
||||
import React from "react";
|
||||
|
||||
export const ManagePayment = () => {
|
||||
return (
|
||||
<>
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
mode: "setup",
|
||||
currency: "usd",
|
||||
paymentMethodCreation: "manual",
|
||||
}}
|
||||
>
|
||||
<ManagePaymentForm />
|
||||
</Elements>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagePayment;
|
||||
633
src/containers/internal/views/accounts/subscription-form.tsx
Normal file
633
src/containers/internal/views/accounts/subscription-form.tsx
Normal file
@@ -0,0 +1,633 @@
|
||||
import { Button, ButtonGroup, H1, P } from "@jambonz/ui-kit";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { postSubscriptions, useApiData } from "src/api";
|
||||
import { CurrencySymbol } from "src/api/constants";
|
||||
import {
|
||||
CurrentUserData,
|
||||
PriceInfo,
|
||||
ServiceData,
|
||||
StripeCustomerId,
|
||||
Subscription,
|
||||
} from "src/api/types";
|
||||
import { Modal, Section } from "src/components";
|
||||
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
||||
import { hasValue } from "src/utils";
|
||||
import {
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { PaymentMethod } from "@stripe/stripe-js";
|
||||
import { toastError, toastSuccess } from "src/store";
|
||||
import { ModalLoader } from "src/components/modal";
|
||||
|
||||
const SubscriptionForm = () => {
|
||||
const [userData] = useApiData<CurrentUserData>("Users/me");
|
||||
const [priceInfo] = useApiData<PriceInfo[]>("/Prices");
|
||||
const [userStripeInfo] = useApiData<StripeCustomerId>("/StripeCustomerId");
|
||||
const [total, setTotal] = useState(0);
|
||||
const [cardErrorCase, setCardErrorCase] = useState(false);
|
||||
const [isReviewChanges, setIsReviewChanges] = useState(false);
|
||||
const [isReturnToFreePlan, setIsReturnToFreePlan] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isModifySubscription = location.pathname.includes(
|
||||
"modify-subscription",
|
||||
);
|
||||
const [billingCharge, setBillingCharge] = useState<Subscription | null>(null);
|
||||
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
|
||||
const [isDisableSubmitButton, setIsDisableSubmitButton] =
|
||||
useState(isModifySubscription);
|
||||
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const createSubscription = async (paymentMethod: PaymentMethod) => {
|
||||
let body: Subscription = {};
|
||||
|
||||
if (cardErrorCase) {
|
||||
body = {
|
||||
action: "update-payment-method",
|
||||
payment_method_id: paymentMethod.id,
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
action: "upgrade-to-paid",
|
||||
payment_method_id: paymentMethod.id,
|
||||
stripe_customer_id: userStripeInfo?.stripe_customer_id,
|
||||
products: serviceData.map((service) => ({
|
||||
price_id: service.stripe_price_id,
|
||||
product_sid: service.product_sid,
|
||||
quantity: service.capacity || 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
postSubscriptions(body)
|
||||
.then(({ json }) => {
|
||||
if (json.status === "success") {
|
||||
toastSuccess("Payment completed successfully");
|
||||
navigate(
|
||||
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
|
||||
);
|
||||
} else if (json.status === "action required") {
|
||||
if (stripe) {
|
||||
const location = window.location;
|
||||
stripe
|
||||
.confirmPayment({
|
||||
clientSecret: json.client_secret || "",
|
||||
confirmParams: {
|
||||
return_url: `${location.protocol}//${location.host}${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
|
||||
},
|
||||
})
|
||||
.then((error) => {
|
||||
if (error) {
|
||||
toastError(error.error.message || "");
|
||||
return;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
});
|
||||
}
|
||||
} else if (json.status === "card error") {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
setCardErrorCase(true);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
toastError(error.msg || "Something went wrong, please try again.");
|
||||
});
|
||||
};
|
||||
|
||||
const retrieveBillingChanges = async () => {
|
||||
const updatedProducts = serviceData.map((product) => ({
|
||||
price_id: product.stripe_price_id,
|
||||
product_sid: product.product_sid,
|
||||
quantity: product.capacity || 0,
|
||||
}));
|
||||
|
||||
postSubscriptions({
|
||||
action: "update-quantities",
|
||||
dry_run: true,
|
||||
products: updatedProducts,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setBillingCharge(json);
|
||||
setIsReviewChanges(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg || "Something went wrong, please try again.");
|
||||
setIsDisableSubmitButton(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDisableSubmitButton(true);
|
||||
if (isModifySubscription) {
|
||||
retrieveBillingChanges();
|
||||
return;
|
||||
}
|
||||
setIsShowModalLoader(true);
|
||||
const { error: elementsError } = await elements.submit();
|
||||
if (elementsError) {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
toastError(elementsError.message || "");
|
||||
return;
|
||||
}
|
||||
const card = elements.getElement(PaymentElement);
|
||||
if (!card) {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
return;
|
||||
}
|
||||
const { error, paymentMethod } = await stripe.createPaymentMethod({
|
||||
element: card,
|
||||
});
|
||||
if (error) {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
toastError(error.message || "");
|
||||
return;
|
||||
}
|
||||
|
||||
createSubscription(paymentMethod);
|
||||
};
|
||||
|
||||
const handleReturnToFreePlan = () => {
|
||||
setIsReturnToFreePlan(false);
|
||||
setIsShowModalLoader(true);
|
||||
const body: Subscription = {
|
||||
action: "downgrade-to-free",
|
||||
};
|
||||
|
||||
postSubscriptions(body)
|
||||
.then(() => {
|
||||
toastSuccess("Downgrade to free plan completed successfully");
|
||||
navigate(
|
||||
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
})
|
||||
.finally(() => setIsShowModalLoader(false));
|
||||
};
|
||||
|
||||
const handleReviewChangeSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsShowModalLoader(true);
|
||||
|
||||
const updatedProducts = serviceData.map((product) => ({
|
||||
price_id: product.stripe_price_id,
|
||||
product_sid: product.product_sid,
|
||||
quantity: product.capacity,
|
||||
}));
|
||||
|
||||
postSubscriptions({
|
||||
action: "update-quantities",
|
||||
products: updatedProducts,
|
||||
})
|
||||
.then(() => {
|
||||
toastSuccess(
|
||||
"Your subscription capacity has been successfully modified.",
|
||||
);
|
||||
navigate(
|
||||
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toastError(
|
||||
`The additional capacity you that you requested could not be granted due to a failure processing payment.
|
||||
Please configure a valid credit card for your account and the upgrade will be automatically processed`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsShowModalLoader(false);
|
||||
setIsDisableSubmitButton(false);
|
||||
});
|
||||
};
|
||||
// subscription categories
|
||||
const [serviceData, setServiceData] = useState<ServiceData[]>([
|
||||
{
|
||||
category: "voice_call_session",
|
||||
name: "concurrent call session",
|
||||
service: "Maximum concurrent call sessions",
|
||||
fees: 0,
|
||||
feesLabel: "",
|
||||
cost: 0,
|
||||
capacity: 0,
|
||||
invalid: false,
|
||||
currency: "usd",
|
||||
min: 5,
|
||||
max: 1000,
|
||||
dirty: false,
|
||||
visible: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
category: "device",
|
||||
name: "registered device",
|
||||
service: "Additional device registrations",
|
||||
fees: 0,
|
||||
feesLabel: "",
|
||||
cost: 0,
|
||||
capacity: 0,
|
||||
invalid: false,
|
||||
currency: "usd",
|
||||
min: 0,
|
||||
max: 200,
|
||||
dirty: false,
|
||||
visible: false,
|
||||
required: false,
|
||||
},
|
||||
]);
|
||||
const [originalServiceData, setOriginalServiceData] = useState<ServiceData[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const initFeesAndCost = (priceData: PriceInfo[]) => {
|
||||
serviceData.forEach((service) => {
|
||||
const record = priceData.find(
|
||||
(item) => item.category === service.category,
|
||||
);
|
||||
|
||||
if (record) {
|
||||
const price = record.prices.find(
|
||||
(item) => item.currency === service.currency,
|
||||
);
|
||||
|
||||
if (price) {
|
||||
let fees = 0;
|
||||
switch (price.billing_scheme) {
|
||||
case "per_unit":
|
||||
fees = (price.unit_amount * 1) / 100;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
service.billing_scheme = price.billing_scheme;
|
||||
service.stripe_price_id = price.stripe_price_id;
|
||||
service.unit_label = record.unit_label;
|
||||
service.product_sid = record.product_sid;
|
||||
service.stripe_product_id = record.stripe_product_id;
|
||||
service.fees = fees;
|
||||
service.feesLabel = `${
|
||||
CurrencySymbol[service.currency || "usd"]
|
||||
}${fees} per ${
|
||||
record.unit_label?.slice(0, 3) === "per"
|
||||
? record.unit_label.slice(3)
|
||||
: record.unit_label
|
||||
}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setServiceData([...serviceData]);
|
||||
};
|
||||
|
||||
const getServicePrice = (
|
||||
service: ServiceData,
|
||||
capacity: number,
|
||||
): [number, string, number] => {
|
||||
let fees = 0;
|
||||
let feesLabel = "";
|
||||
let cost = 0;
|
||||
const capacityNum = capacity;
|
||||
if (service.billing_scheme === "per_unit") {
|
||||
fees = service.fees;
|
||||
cost = fees * capacityNum;
|
||||
} else if (service.billing_scheme === "tiered") {
|
||||
const filteredTiers = service.tiers
|
||||
? service.tiers.filter(
|
||||
(item) => !item.up_to || item.up_to >= capacityNum,
|
||||
)
|
||||
: [];
|
||||
if (filteredTiers.length) {
|
||||
const tier = filteredTiers[0];
|
||||
if (typeof tier.flat_amount === "number") {
|
||||
fees = tier.flat_amount / 100;
|
||||
cost = fees;
|
||||
} else {
|
||||
fees = tier.unit_amount / 100;
|
||||
cost = fees * capacityNum;
|
||||
}
|
||||
}
|
||||
}
|
||||
feesLabel = `${CurrencySymbol[service.currency || "usd"]}${fees} per ${
|
||||
service.unit_label && service.unit_label.slice(0, 3) === "per"
|
||||
? service.unit_label.slice(3)
|
||||
: service.unit_label
|
||||
}`;
|
||||
|
||||
return [fees, feesLabel, cost];
|
||||
};
|
||||
|
||||
const setProductsInfo = (data: CurrentUserData) => {
|
||||
const { products } = data.subscription || {};
|
||||
|
||||
const services = serviceData.map((service) => {
|
||||
const { quantity } = products
|
||||
? products.find((item) => item.name === service.name) || {}
|
||||
: { quantity: null };
|
||||
const [fees, feesLabel, cost] = getServicePrice(service, quantity || 0);
|
||||
return {
|
||||
...service,
|
||||
capacity: quantity || 0,
|
||||
invalid: false,
|
||||
fees,
|
||||
feesLabel,
|
||||
cost,
|
||||
visible: hasValue(quantity) && quantity > 0,
|
||||
};
|
||||
});
|
||||
|
||||
setServiceData(services);
|
||||
setOriginalServiceData([...services]);
|
||||
};
|
||||
|
||||
const updateServiceData = (
|
||||
index: number,
|
||||
key: string,
|
||||
value: (typeof serviceData)[number][keyof ServiceData],
|
||||
) => {
|
||||
setServiceData(
|
||||
serviceData.map((g, i) =>
|
||||
i === index
|
||||
? {
|
||||
...g,
|
||||
[key]: value,
|
||||
...(key === "capacity" && { cost: Number(value) * g.fees }),
|
||||
}
|
||||
: g,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (priceInfo) {
|
||||
initFeesAndCost(priceInfo);
|
||||
}
|
||||
|
||||
if (userData && priceInfo) {
|
||||
setProductsInfo(userData);
|
||||
}
|
||||
}, [priceInfo, userData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModifySubscription && originalServiceData.length > 0) {
|
||||
setIsDisableSubmitButton(
|
||||
serviceData[0].capacity === originalServiceData[0].capacity &&
|
||||
serviceData[1].capacity === originalServiceData[1].capacity,
|
||||
);
|
||||
}
|
||||
setTotal(serviceData.reduce((res, service) => res + service.cost || 0, 0));
|
||||
}, [serviceData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">
|
||||
{isModifySubscription
|
||||
? "Configure Your Subscription"
|
||||
: "Upgrade your Subscription"}
|
||||
</H1>
|
||||
{isShowModalLoader && (
|
||||
<ModalLoader>
|
||||
<P>
|
||||
Your requested changes are being processed. Please do not leave the
|
||||
page or hit the back button until complete.
|
||||
</P>
|
||||
</ModalLoader>
|
||||
)}
|
||||
{isReviewChanges && !isShowModalLoader && (
|
||||
<Modal
|
||||
handleCancel={() => {
|
||||
setIsReviewChanges(false);
|
||||
setIsDisableSubmitButton(false);
|
||||
}}
|
||||
handleSubmit={handleReviewChangeSubmit}
|
||||
>
|
||||
<H1 className="h4">Confirm Changes</H1>
|
||||
<P>
|
||||
By pressing{" "}
|
||||
<span>
|
||||
<strong>Confirm</strong>
|
||||
</span>{" "}
|
||||
below, your plan will be immediately adjusted to the following
|
||||
levels:
|
||||
</P>
|
||||
<ul className="m">
|
||||
<li>{`- ${serviceData[0].capacity} simultaneous calls`}</li>
|
||||
{userData?.account && userData?.account.device_to_call_ratio && (
|
||||
<li>{`- ${
|
||||
userData?.account.device_to_call_ratio *
|
||||
(serviceData[0].capacity + serviceData[1].capacity)
|
||||
} registered devices`}</li>
|
||||
)}
|
||||
</ul>
|
||||
<P>
|
||||
{(billingCharge?.prorated_cost || 0) > 0 &&
|
||||
`Your new monthly charge will be $${
|
||||
(billingCharge?.monthly_cost || 0) / 100
|
||||
}, and you will immediately be charged a one-time prorated amount of $${
|
||||
(billingCharge?.prorated_cost || 0) / 100
|
||||
} to cover the remainder of the current billing period.`}
|
||||
{billingCharge?.prorated_cost === 0 &&
|
||||
`Your monthly charge will be $${
|
||||
(billingCharge.monthly_cost || 0) / 100
|
||||
}.`}
|
||||
{(billingCharge?.prorated_cost || 0) < 0 &&
|
||||
`Your new monthly charge will be $${
|
||||
(billingCharge?.monthly_cost || 0) / 100
|
||||
}, and you will receive a credit of $${
|
||||
-(billingCharge?.prorated_cost || 0) / 100
|
||||
} on your next invoice to reflect changes made during the current billing period.`}
|
||||
</P>
|
||||
</Modal>
|
||||
)}
|
||||
{isReturnToFreePlan && !isShowModalLoader && (
|
||||
<Modal
|
||||
handleCancel={() => setIsReturnToFreePlan(false)}
|
||||
handleSubmit={handleReturnToFreePlan}
|
||||
>
|
||||
<H1 className="h4">Return to Free Plan</H1>
|
||||
<P>
|
||||
Returning to the free plan will reduce your capacity to a maximum of
|
||||
1 simultaneous call session and 1 registered device. Your current
|
||||
plan and capacity will continue through the rest of the billing
|
||||
cycle and your plan change will take effect at the beginning of the
|
||||
next billing cycle. Are you sure you want to continue?
|
||||
</P>
|
||||
</Modal>
|
||||
)}
|
||||
<Section slim>
|
||||
<form className="form form--internal" onSubmit={handleSubmit}>
|
||||
<div className="grid grid--col4--users">
|
||||
<div className="grid__row grid__th">
|
||||
<div>Service</div>
|
||||
<div>Capacity</div>
|
||||
<div>Price</div>
|
||||
<div>Cost</div>
|
||||
</div>
|
||||
|
||||
{serviceData &&
|
||||
serviceData
|
||||
.filter((service) => service.visible)
|
||||
.map((service, idx) => (
|
||||
<React.Fragment key={`subscription-${idx}`}>
|
||||
<div className="grid__row">
|
||||
<div>
|
||||
<label htmlFor={service.name || ""}>
|
||||
{service.service}
|
||||
<span>*</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
id="tech_prefix"
|
||||
name="tech_prefix"
|
||||
type="number"
|
||||
value={service.capacity}
|
||||
required
|
||||
min={service.min}
|
||||
max={service.max}
|
||||
onChange={(e) => {
|
||||
updateServiceData(
|
||||
idx,
|
||||
"capacity",
|
||||
e.target.value ? Number(e.target.value) : "",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<em>{service.feesLabel}</em>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<P>
|
||||
<strong>
|
||||
{CurrencySymbol[service.currency || "usd"]}
|
||||
{service.cost}
|
||||
</strong>
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{serviceData[0].capacity !== 0 && !serviceData[1].visible && (
|
||||
<>
|
||||
<div className="grid__row">
|
||||
<label htmlFor="max_concurrent_call_sessons">
|
||||
{`With ${
|
||||
serviceData[0].capacity
|
||||
} call sessions you can register ${
|
||||
serviceData[0].capacity *
|
||||
(userData?.account?.device_to_call_ratio || 0)
|
||||
} concurrent devices`}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
mainStyle="hollow"
|
||||
onClick={() =>
|
||||
setServiceData((prev) => {
|
||||
prev[1].visible = true;
|
||||
return [...prev];
|
||||
})
|
||||
}
|
||||
>
|
||||
Would you like to purchase additional device
|
||||
registrations?
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="grid__row">
|
||||
<div>
|
||||
<label htmlFor="total">Total Monthly Cost</label>
|
||||
</div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div>
|
||||
<P>
|
||||
<strong>
|
||||
{CurrencySymbol[serviceData[0].currency || "usd"]}
|
||||
{total}
|
||||
</strong>
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
{!isModifySubscription && (
|
||||
<fieldset>
|
||||
<label htmlFor="total">Payment Information</label>
|
||||
<div className="grid__row">
|
||||
<div></div>
|
||||
<div>
|
||||
<PaymentElement
|
||||
options={{
|
||||
paymentMethodOrder: ["card"],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
<fieldset>
|
||||
<>
|
||||
<div className={isModifySubscription ? "mast" : ""}>
|
||||
{isModifySubscription && (
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
type="button"
|
||||
subStyle="grey"
|
||||
mainStyle="hollow"
|
||||
onClick={() => setIsReturnToFreePlan(true)}
|
||||
small
|
||||
>
|
||||
Return to free plan
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`}
|
||||
small
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isDisableSubmitButton} small>
|
||||
{isModifySubscription
|
||||
? "Review Changes"
|
||||
: `Pay ${CurrencySymbol[serviceData[0].currency || "usd"]}
|
||||
${total} and Upgrade to Paid Plan`}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionForm;
|
||||
32
src/containers/internal/views/accounts/subscription.tsx
Normal file
32
src/containers/internal/views/accounts/subscription.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import {
|
||||
ENABLE_HOSTED_SYSTEM,
|
||||
STRIPE_PUBLISHABLE_KEY,
|
||||
} from "src/api/constants";
|
||||
|
||||
import { Elements } from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import SubscriptionForm from "./subscription-form";
|
||||
|
||||
export const stripePromise = ENABLE_HOSTED_SYSTEM
|
||||
? loadStripe(STRIPE_PUBLISHABLE_KEY)
|
||||
: null;
|
||||
|
||||
export const Subscription = () => {
|
||||
return (
|
||||
<>
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
mode: "setup",
|
||||
currency: "usd",
|
||||
paymentMethodCreation: "manual",
|
||||
}}
|
||||
>
|
||||
<SubscriptionForm />
|
||||
</Elements>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Subscription;
|
||||
59
src/containers/internal/views/alerts/alert-detail-item.tsx
Normal file
59
src/containers/internal/views/alerts/alert-detail-item.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import dayjs from "dayjs";
|
||||
import React, { useState } from "react";
|
||||
import { Alert } from "src/api/types";
|
||||
import { Icons } from "src/components";
|
||||
|
||||
type AlertDetailsItemProps = {
|
||||
alert: Alert;
|
||||
};
|
||||
|
||||
export const AlertDetailItem = ({ alert }: AlertDetailsItemProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="item">
|
||||
<details
|
||||
className="clean"
|
||||
onToggle={(e: React.BaseSyntheticEvent) => {
|
||||
if (e.target.open && !open) {
|
||||
setOpen(e.target.open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<summary className="txt--jam">
|
||||
<div className="item__info">
|
||||
<div className="item__title">
|
||||
<strong>
|
||||
{dayjs(alert.time).format("YYYY MM.DD hh:mm:ss a")}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="item__meta">
|
||||
<div>
|
||||
<div className="i txt--teal">
|
||||
<Icons.AlertCircle />
|
||||
<span>{alert.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="item__details">
|
||||
<div className="pre-grid">
|
||||
{Object.keys(alert).map((key) => (
|
||||
<React.Fragment key={key}>
|
||||
<div>{key}:</div>
|
||||
<div>
|
||||
{alert[key as keyof typeof alert]
|
||||
? String(alert[key as keyof typeof alert])
|
||||
: "null"}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertDetailItem;
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Section,
|
||||
SelectFilter,
|
||||
Spinner,
|
||||
Icons,
|
||||
} from "src/components";
|
||||
|
||||
import type { Account, Alert, PageQuery } from "src/api/types";
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
getQueryFilter,
|
||||
setLocation,
|
||||
} from "src/store/localStore";
|
||||
import AlertDetailItem from "./alert-detail-item";
|
||||
|
||||
export const Alerts = () => {
|
||||
const user = useSelectState("user");
|
||||
@@ -47,7 +47,12 @@ export const Alerts = () => {
|
||||
count: Number(perPageFilter),
|
||||
...(dateFilter === "today"
|
||||
? { start: dayjs().startOf("date").toISOString() }
|
||||
: { days: Number(dateFilter) }),
|
||||
: dateFilter === "yesterday"
|
||||
? {
|
||||
start: dayjs().subtract(1, "day").startOf("day").toISOString(),
|
||||
end: dayjs().subtract(1, "day").endOf("day").toISOString(),
|
||||
}
|
||||
: { days: Number(dateFilter) }),
|
||||
};
|
||||
|
||||
getAlerts(accountSid, payload)
|
||||
@@ -103,7 +108,7 @@ export const Alerts = () => {
|
||||
id="date_filter"
|
||||
label="Date"
|
||||
filter={[dateFilter, setDateFilter]}
|
||||
options={DATE_SELECTION.slice(0, 2)}
|
||||
options={DATE_SELECTION}
|
||||
/>
|
||||
</section>
|
||||
<Section {...(hasLength(alerts) && { slim: true })}>
|
||||
@@ -112,21 +117,7 @@ export const Alerts = () => {
|
||||
<Spinner />
|
||||
) : hasLength(alerts) ? (
|
||||
alerts.map((alert) => (
|
||||
<div className="item" key={`${alert.alert_type}-${alert.time}`}>
|
||||
<div className="item__info">
|
||||
<div className="item__title txt--jam">
|
||||
<strong>
|
||||
{dayjs(alert.time).format("YYYY MM.DD hh:mm a")}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="item__meta">
|
||||
<div className="i">
|
||||
<Icons.AlertCircle />
|
||||
<span>{alert.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDetailItem key={alert.time} alert={alert} />
|
||||
))
|
||||
) : (
|
||||
<M>No data.</M>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -16,10 +16,7 @@ import {
|
||||
LANG_EN_US,
|
||||
VENDOR_GOOGLE,
|
||||
LANG_EN_US_STANDARD_C,
|
||||
VENDOR_AWS,
|
||||
VENDOR_WELLSAID,
|
||||
useSpeechVendors,
|
||||
VENDOR_DEEPGRAM,
|
||||
VENDOR_CUSTOM,
|
||||
} from "src/vendor";
|
||||
import {
|
||||
postApplication,
|
||||
@@ -31,14 +28,17 @@ import {
|
||||
ROUTE_INTERNAL_ACCOUNTS,
|
||||
ROUTE_INTERNAL_APPLICATIONS,
|
||||
} from "src/router/routes";
|
||||
import { DEFAULT_WEBHOOK, WEBHOOK_METHODS } from "src/api/constants";
|
||||
import {
|
||||
DEFAULT_WEBHOOK,
|
||||
DISABLE_CALL_RECORDING,
|
||||
WEBHOOK_METHODS,
|
||||
} from "src/api/constants";
|
||||
|
||||
import type {
|
||||
RecognizerVendors,
|
||||
SynthesisVendors,
|
||||
Voice,
|
||||
VoiceLanguage,
|
||||
Language,
|
||||
VendorOptions,
|
||||
LabelOptions,
|
||||
} from "src/vendor/types";
|
||||
|
||||
import type {
|
||||
@@ -47,10 +47,12 @@ import type {
|
||||
Application,
|
||||
WebhookMethod,
|
||||
UseApiDataMap,
|
||||
SpeechCredential,
|
||||
} from "src/api/types";
|
||||
import { MSG_REQUIRED_FIELDS, MSG_WEBHOOK_FIELDS } from "src/constants";
|
||||
import { isUserAccountScope, useRedirect } from "src/utils";
|
||||
import { hasLength, isUserAccountScope, useRedirect } from "src/utils";
|
||||
import { setAccountFilter, setLocation } from "src/store/localStore";
|
||||
import SpeechProviderSelection from "./speech-selection";
|
||||
|
||||
type ApplicationFormProps = {
|
||||
application?: UseApiDataMap<Application>;
|
||||
@@ -58,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("");
|
||||
@@ -68,12 +70,13 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
const [initialApplicationJson, setInitialApplicationJson] = useState(false);
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
const [callWebhook, setCallWebhook] = useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [tmpCallWebhook, setTmpCallWebhook] =
|
||||
useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [initialCallWebhook, setInitialCallWebhook] = useState(false);
|
||||
const [statusWebhook, setStatusWebhook] = useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [initialStatusWebhook, setInitialStatusWebhook] = useState(false);
|
||||
const [messageWebhook, setMessageWebhook] =
|
||||
const [tmpStatusWebhook, setTmpStatusWebhook] =
|
||||
useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [initialMessageWebhook, setInitialMessageWebhook] = useState(false);
|
||||
const [initialStatusWebhook, setInitialStatusWebhook] = useState(false);
|
||||
const [synthVendor, setSynthVendor] =
|
||||
useState<keyof SynthesisVendors>(VENDOR_GOOGLE);
|
||||
const [synthLang, setSynthLang] = useState(LANG_EN_US);
|
||||
@@ -82,6 +85,43 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
useState<keyof RecognizerVendors>(VENDOR_GOOGLE);
|
||||
const [recogLang, setRecogLang] = useState(LANG_EN_US);
|
||||
const [message, setMessage] = useState("");
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [credentials] = useApiData<SpeechCredential[]>(apiUrl);
|
||||
const [ttsVendorOptions, setttsVendorOptions] =
|
||||
useState<VendorOptions[]>(vendors);
|
||||
const [sttVendorOptions, setSttVendorOptions] =
|
||||
useState<VendorOptions[]>(vendors);
|
||||
const [recogLabel, setRecogLabel] = useState("");
|
||||
const [ttsLabelOptions, setTtsLabelOptions] = useState<LabelOptions[]>([]);
|
||||
const [sttLabelOptions, setSttLabelOptions] = useState<LabelOptions[]>([]);
|
||||
const [fallbackTtsLabelOptions, setFallbackTtsLabelOptions] = useState<
|
||||
LabelOptions[]
|
||||
>([]);
|
||||
const [fallbackSttLabelOptions, setFallbackSttLabelOptions] = useState<
|
||||
LabelOptions[]
|
||||
>([]);
|
||||
const [synthLabel, setSynthLabel] = useState("");
|
||||
const [recordAllCalls, setRecordAllCalls] = useState(false);
|
||||
|
||||
const [useForFallbackSpeech, setUseForFallbackSpeech] = useState(false);
|
||||
const [fallbackSpeechSynthsisVendor, setFallbackSpeechSynthsisVendor] =
|
||||
useState<keyof SynthesisVendors>(VENDOR_GOOGLE);
|
||||
const [fallbackSpeechSynthsisLanguage, setFallbackSpeechSynthsisLanguage] =
|
||||
useState(LANG_EN_US);
|
||||
const [fallbackSpeechSynthsisVoice, setFallbackSpeechSynthsisVoice] =
|
||||
useState(LANG_EN_US_STANDARD_C);
|
||||
const [fallbackSpeechSynthsisLabel, setFallbackSpeechSynthsisLabel] =
|
||||
useState("");
|
||||
const [fallbackSpeechRecognizerVendor, setFallbackSpeechRecognizerVendor] =
|
||||
useState<keyof RecognizerVendors>(VENDOR_GOOGLE);
|
||||
const [
|
||||
fallbackSpeechRecognizerLanguage,
|
||||
setFallbackSpeechRecognizerLanguage,
|
||||
] = useState(LANG_EN_US);
|
||||
const [fallbackSpeechRecognizerLabel, setFallbackSpeechRecognizerLabel] =
|
||||
useState("");
|
||||
const [initalCheckFallbackSpeech, setInitalCheckFallbackSpeech] =
|
||||
useState(false);
|
||||
|
||||
/** This lets us map and render the same UI for each... */
|
||||
const webhooks = [
|
||||
@@ -89,7 +129,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
label: "Calling",
|
||||
prefix: "call_webhook",
|
||||
stateVal: callWebhook,
|
||||
tmpStateVal: tmpCallWebhook,
|
||||
stateSet: setCallWebhook,
|
||||
tmpStateSet: setTmpCallWebhook,
|
||||
initialCheck: initialCallWebhook,
|
||||
required: true,
|
||||
},
|
||||
@@ -97,24 +139,18 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
label: "Call status",
|
||||
prefix: "status_webhook",
|
||||
stateVal: statusWebhook,
|
||||
tmpStateVal: tmpStatusWebhook,
|
||||
stateSet: setStatusWebhook,
|
||||
tmpStateSet: setTmpStatusWebhook,
|
||||
initialCheck: initialStatusWebhook,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: "Messaging",
|
||||
prefix: "message_webhook",
|
||||
stateVal: messageWebhook,
|
||||
stateSet: setMessageWebhook,
|
||||
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) => {
|
||||
@@ -122,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;
|
||||
}
|
||||
@@ -136,28 +172,52 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
a.name === applicationName &&
|
||||
(!application ||
|
||||
!application.data ||
|
||||
a.application_sid !== application.data.application_sid)
|
||||
a.application_sid !== application.data.application_sid),
|
||||
)
|
||||
) {
|
||||
setMessage(
|
||||
"The name you have entered is already in use on another one of your applications."
|
||||
"The name you have entered is already in use on another one of your applications.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload: Partial<Application> = {
|
||||
name: applicationName,
|
||||
app_json: applicationJson || null,
|
||||
call_hook: callWebhook || null,
|
||||
account_sid: accountSid || null,
|
||||
messaging_hook: messageWebhook || null,
|
||||
call_status_hook: statusWebhook || null,
|
||||
speech_synthesis_vendor: synthVendor || null,
|
||||
speech_synthesis_language: synthLang || null,
|
||||
speech_synthesis_label: synthLabel || null,
|
||||
speech_synthesis_voice: synthVoice || null,
|
||||
speech_recognizer_vendor: recogVendor || null,
|
||||
speech_recognizer_language: recogLang || null,
|
||||
speech_recognizer_label: recogLabel || null,
|
||||
record_all_calls: recordAllCalls ? 1 : 0,
|
||||
use_for_fallback_speech: useForFallbackSpeech ? 1 : 0,
|
||||
fallback_speech_synthesis_vendor: useForFallbackSpeech
|
||||
? fallbackSpeechSynthsisVendor || null
|
||||
: null,
|
||||
fallback_speech_synthesis_language: useForFallbackSpeech
|
||||
? fallbackSpeechSynthsisLanguage || null
|
||||
: null,
|
||||
fallback_speech_synthesis_voice: useForFallbackSpeech
|
||||
? fallbackSpeechSynthsisVoice || null
|
||||
: null,
|
||||
fallback_speech_synthesis_label: useForFallbackSpeech
|
||||
? fallbackSpeechSynthsisLabel || null
|
||||
: null,
|
||||
fallback_speech_recognizer_vendor: useForFallbackSpeech
|
||||
? fallbackSpeechRecognizerVendor || null
|
||||
: null,
|
||||
fallback_speech_recognizer_language: useForFallbackSpeech
|
||||
? fallbackSpeechRecognizerLanguage || null
|
||||
: null,
|
||||
fallback_speech_recognizer_label: useForFallbackSpeech
|
||||
? fallbackSpeechRecognizerLabel || null
|
||||
: null,
|
||||
};
|
||||
|
||||
if (application && application.data) {
|
||||
@@ -166,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) => {
|
||||
@@ -185,21 +245,195 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
useMemo(() => {
|
||||
if (credentials && hasLength(credentials)) {
|
||||
const v = credentials
|
||||
.filter((tv) => tv.vendor.startsWith(VENDOR_CUSTOM) && tv.use_for_tts)
|
||||
.map((tv) =>
|
||||
Object.assign({
|
||||
name:
|
||||
tv.vendor.substring(VENDOR_CUSTOM.length + 1) +
|
||||
` (${VENDOR_CUSTOM})`,
|
||||
value: tv.vendor,
|
||||
}),
|
||||
);
|
||||
setttsVendorOptions(vendors.concat(v));
|
||||
|
||||
const v2 = credentials
|
||||
.filter((tv) => tv.vendor.startsWith(VENDOR_CUSTOM) && tv.use_for_stt)
|
||||
.map((tv) =>
|
||||
Object.assign({
|
||||
name:
|
||||
tv.vendor.substring(VENDOR_CUSTOM.length + 1) +
|
||||
` (${VENDOR_CUSTOM})`,
|
||||
value: tv.vendor,
|
||||
}),
|
||||
);
|
||||
setSttVendorOptions(vendors.concat(v2));
|
||||
|
||||
const noneLabelObject = {
|
||||
name: "None",
|
||||
value: "",
|
||||
};
|
||||
|
||||
let c1 = credentials.filter(
|
||||
(c) =>
|
||||
c.vendor === synthVendor &&
|
||||
(!c.account_sid || c.account_sid === accountSid) &&
|
||||
c.use_for_tts,
|
||||
);
|
||||
let c2 = c1
|
||||
.filter((c) => c.label)
|
||||
.map((c) =>
|
||||
Object.assign({
|
||||
name: c.label,
|
||||
value: c.label,
|
||||
}),
|
||||
);
|
||||
|
||||
setTtsLabelOptions(
|
||||
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2,
|
||||
);
|
||||
|
||||
c1 = fallbackSpeechSynthsisVendor
|
||||
? credentials.filter(
|
||||
(c) =>
|
||||
c.vendor === fallbackSpeechSynthsisVendor &&
|
||||
(!c.account_sid || c.account_sid === accountSid) &&
|
||||
c.use_for_tts,
|
||||
)
|
||||
: [];
|
||||
|
||||
c2 = c1
|
||||
.filter((c) => c.label)
|
||||
.map((c) =>
|
||||
Object.assign({
|
||||
name: c.label,
|
||||
value: c.label,
|
||||
}),
|
||||
);
|
||||
setFallbackTtsLabelOptions(
|
||||
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2,
|
||||
);
|
||||
|
||||
c1 = credentials.filter(
|
||||
(c) =>
|
||||
c.vendor === recogVendor &&
|
||||
(!c.account_sid || c.account_sid === accountSid) &&
|
||||
c.use_for_stt,
|
||||
);
|
||||
c2 = c1
|
||||
.filter((c) => c.label)
|
||||
.map((c) =>
|
||||
Object.assign({
|
||||
name: c.label,
|
||||
value: c.label,
|
||||
}),
|
||||
);
|
||||
|
||||
setSttLabelOptions(
|
||||
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2,
|
||||
);
|
||||
|
||||
c1 = fallbackSpeechRecognizerVendor
|
||||
? credentials.filter(
|
||||
(c) =>
|
||||
c.vendor === fallbackSpeechRecognizerVendor &&
|
||||
(!c.account_sid || c.account_sid === accountSid) &&
|
||||
c.use_for_stt,
|
||||
)
|
||||
: [];
|
||||
c2 = c1
|
||||
.filter((c) => c.label)
|
||||
.map((c) =>
|
||||
Object.assign({
|
||||
name: c.label,
|
||||
value: c.label,
|
||||
}),
|
||||
);
|
||||
|
||||
setFallbackSttLabelOptions(
|
||||
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
credentials,
|
||||
synthVendor,
|
||||
recogVendor,
|
||||
fallbackSpeechRecognizerVendor,
|
||||
fallbackSpeechSynthsisVendor,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountSid) {
|
||||
setApiUrl(`Accounts/${accountSid}/SpeechCredentials`);
|
||||
}
|
||||
}, [accountSid]);
|
||||
|
||||
useEffect(() => {
|
||||
let label: string;
|
||||
// Synthesis Label
|
||||
label = application?.data?.speech_synthesis_label || "";
|
||||
if (ttsLabelOptions && !ttsLabelOptions.find((l) => l.value === label)) {
|
||||
label = ttsLabelOptions.length ? ttsLabelOptions[0].value : "";
|
||||
}
|
||||
setSynthLabel(label);
|
||||
|
||||
// fallback Synthesis Label
|
||||
label = application?.data?.fallback_speech_synthesis_label || "";
|
||||
if (
|
||||
fallbackTtsLabelOptions &&
|
||||
!fallbackTtsLabelOptions.find((l) => l.value === label)
|
||||
) {
|
||||
label = fallbackTtsLabelOptions.length
|
||||
? fallbackTtsLabelOptions[0].value
|
||||
: "";
|
||||
}
|
||||
setFallbackSpeechSynthsisLabel(label);
|
||||
|
||||
// regconizer label
|
||||
label = application?.data?.speech_recognizer_label || "";
|
||||
if (sttLabelOptions && !sttLabelOptions.find((l) => l.value === label)) {
|
||||
label = sttLabelOptions.length ? sttLabelOptions[0].value : "";
|
||||
}
|
||||
setRecogLabel(label);
|
||||
|
||||
// fallback regconizer label
|
||||
label = application?.data?.fallback_speech_recognizer_label || "";
|
||||
if (
|
||||
fallbackSttLabelOptions &&
|
||||
!fallbackSttLabelOptions.find((l) => l.value === label)
|
||||
) {
|
||||
label = fallbackSttLabelOptions.length
|
||||
? fallbackSttLabelOptions[0].value
|
||||
: "";
|
||||
}
|
||||
setFallbackSpeechRecognizerLabel(label);
|
||||
}, [
|
||||
ttsLabelOptions,
|
||||
sttLabelOptions,
|
||||
fallbackTtsLabelOptions,
|
||||
fallbackSttLabelOptions,
|
||||
application,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocation();
|
||||
if (application && application.data) {
|
||||
setApplicationName(application.data.name);
|
||||
setRecordAllCalls(application.data.record_all_calls ? true : false);
|
||||
if (!applicationJson) {
|
||||
setApplicationJson(application.data.app_json || "");
|
||||
}
|
||||
setTmpApplicationJson(applicationJson);
|
||||
setInitialApplicationJson(
|
||||
application.data.app_json != undefined &&
|
||||
application.data.app_json.length !== 0
|
||||
application.data.app_json.length !== 0,
|
||||
);
|
||||
|
||||
if (application.data.call_hook) {
|
||||
setCallWebhook(application.data.call_hook);
|
||||
setTmpCallWebhook(application.data.call_hook);
|
||||
|
||||
if (
|
||||
application.data.call_hook.username ||
|
||||
@@ -211,6 +445,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
|
||||
if (application.data.call_status_hook) {
|
||||
setStatusWebhook(application.data.call_status_hook);
|
||||
setTmpStatusWebhook(application.data.call_status_hook);
|
||||
|
||||
if (
|
||||
application.data.call_status_hook.username ||
|
||||
@@ -220,31 +455,17 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
else setInitialStatusWebhook(false);
|
||||
}
|
||||
|
||||
if (application.data.messaging_hook) {
|
||||
setMessageWebhook(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)
|
||||
@@ -252,17 +473,89 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
|
||||
if (application.data.speech_recognizer_vendor)
|
||||
setRecogVendor(
|
||||
application.data.speech_recognizer_vendor as keyof RecognizerVendors
|
||||
application.data.speech_recognizer_vendor as keyof RecognizerVendors,
|
||||
);
|
||||
|
||||
if (application.data.speech_recognizer_language)
|
||||
setRecogLang(application.data.speech_recognizer_language);
|
||||
|
||||
if (application.data.use_for_fallback_speech) {
|
||||
setUseForFallbackSpeech(application.data.use_for_fallback_speech > 0);
|
||||
setInitalCheckFallbackSpeech(
|
||||
application.data.use_for_fallback_speech > 0,
|
||||
);
|
||||
}
|
||||
if (application.data.fallback_speech_recognizer_vendor) {
|
||||
setFallbackSpeechRecognizerVendor(
|
||||
application.data
|
||||
.fallback_speech_recognizer_vendor as keyof RecognizerVendors,
|
||||
);
|
||||
}
|
||||
if (application.data.fallback_speech_recognizer_language) {
|
||||
setFallbackSpeechRecognizerLanguage(
|
||||
application.data.fallback_speech_recognizer_language,
|
||||
);
|
||||
}
|
||||
|
||||
if (application.data.fallback_speech_synthesis_vendor) {
|
||||
setFallbackSpeechSynthsisVendor(
|
||||
application.data
|
||||
.fallback_speech_synthesis_vendor as keyof SynthesisVendors,
|
||||
);
|
||||
}
|
||||
if (application.data.fallback_speech_synthesis_language) {
|
||||
setFallbackSpeechSynthsisLanguage(
|
||||
application.data.fallback_speech_synthesis_language,
|
||||
);
|
||||
}
|
||||
if (application.data.fallback_speech_synthesis_voice) {
|
||||
setFallbackSpeechSynthsisVoice(
|
||||
application.data.fallback_speech_synthesis_voice,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [application]);
|
||||
|
||||
const swapPrimaryAndfalloverSpeech = () => {
|
||||
let tmp;
|
||||
|
||||
tmp = synthVendor;
|
||||
setSynthVendor(fallbackSpeechSynthsisVendor);
|
||||
setFallbackSpeechSynthsisVendor(tmp);
|
||||
|
||||
tmp = synthLang;
|
||||
setSynthLang(fallbackSpeechSynthsisLanguage);
|
||||
setFallbackSpeechSynthsisLanguage(synthLang);
|
||||
|
||||
tmp = synthVoice;
|
||||
setSynthVoice(fallbackSpeechSynthsisVoice);
|
||||
setFallbackSpeechSynthsisVoice(tmp);
|
||||
|
||||
tmp = synthLabel;
|
||||
setSynthLabel(fallbackSpeechSynthsisLabel);
|
||||
setFallbackSpeechSynthsisLabel(tmp);
|
||||
|
||||
tmp = recogVendor;
|
||||
setRecogVendor(fallbackSpeechRecognizerVendor);
|
||||
setFallbackSpeechRecognizerVendor(tmp);
|
||||
|
||||
tmp = recogLang;
|
||||
setRecogLang(fallbackSpeechRecognizerLanguage);
|
||||
setFallbackSpeechRecognizerLanguage(tmp);
|
||||
|
||||
tmp = recogLabel;
|
||||
setRecogLabel(fallbackSpeechRecognizerLabel);
|
||||
setFallbackSpeechRecognizerLabel(tmp);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section slim>
|
||||
<form className="form form--internal" onSubmit={handleSubmit}>
|
||||
<form
|
||||
className={`form form--internal ${
|
||||
!application?.data && application?.refetch ? "form--blur" : ""
|
||||
}`}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<fieldset>
|
||||
<MS>{MSG_REQUIRED_FIELDS}</MS>
|
||||
</fieldset>
|
||||
@@ -316,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>
|
||||
@@ -332,6 +647,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
method: e.target.value as WebhookMethod,
|
||||
});
|
||||
}}
|
||||
disabled={webhook.stateVal?.url.startsWith("ws")}
|
||||
options={WEBHOOK_METHODS}
|
||||
/>
|
||||
</div>
|
||||
@@ -341,6 +657,18 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
name={webhook.prefix}
|
||||
label="Use HTTP basic authentication"
|
||||
initialCheck={webhook.initialCheck}
|
||||
handleChecked={(e) => {
|
||||
if (e.target.checked) {
|
||||
webhook.stateSet(webhook.tmpStateVal);
|
||||
} else {
|
||||
webhook.tmpStateSet(webhook.stateVal);
|
||||
webhook.stateSet({
|
||||
...webhook.stateVal,
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MS>{MSG_WEBHOOK_FIELDS}</MS>
|
||||
<label htmlFor={`${webhook.prefix}_username`}>Username</label>
|
||||
@@ -356,13 +684,6 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
username: e.target.value,
|
||||
});
|
||||
}}
|
||||
required={
|
||||
webhook.required &&
|
||||
!webhook.stateVal.username &&
|
||||
webhook.stateVal.password
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`${webhook.prefix}_password`}>Password</label>
|
||||
<Passwd
|
||||
@@ -376,163 +697,90 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
password: e.target.value,
|
||||
});
|
||||
}}
|
||||
required={
|
||||
webhook.required &&
|
||||
webhook.stateVal.username &&
|
||||
!webhook.stateVal.password
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
</Checkzone>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
{synthesis && (
|
||||
<fieldset>
|
||||
<label htmlFor="synthesis_vendor">Speech synthesis vendor</label>
|
||||
<Selector
|
||||
id="synthesis_vendor"
|
||||
name="synthesis_vendor"
|
||||
value={synthVendor}
|
||||
options={vendors.filter(
|
||||
(vendor) => vendor.value != VENDOR_DEEPGRAM
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const vendor = e.target.value as keyof SynthesisVendors;
|
||||
setSynthVendor(vendor);
|
||||
<SpeechProviderSelection
|
||||
serviceProviderSid={
|
||||
currentServiceProvider?.service_provider_sid || ""
|
||||
}
|
||||
accountSid={accountSid}
|
||||
credentials={credentials}
|
||||
ttsVendor={[synthVendor, setSynthVendor]}
|
||||
ttsVendorOptions={ttsVendorOptions}
|
||||
ttsVoice={[synthVoice, setSynthVoice]}
|
||||
ttsLang={[synthLang, setSynthLang]}
|
||||
ttsLabelOptions={ttsLabelOptions}
|
||||
ttsLabel={[synthLabel, setSynthLabel]}
|
||||
sttVendor={[recogVendor, setRecogVendor]}
|
||||
sttVendorOptions={sttVendorOptions}
|
||||
sttLang={[recogLang, setRecogLang]}
|
||||
sttLabelOptions={sttLabelOptions}
|
||||
sttLabel={[recogLabel, setRecogLabel]}
|
||||
/>
|
||||
|
||||
/** When using Google and en-US, ensure "Standard-C" is used as default */
|
||||
if (
|
||||
e.target.value === VENDOR_GOOGLE &&
|
||||
synthLang === LANG_EN_US
|
||||
) {
|
||||
setSynthVoice(LANG_EN_US_STANDARD_C);
|
||||
return;
|
||||
}
|
||||
|
||||
/** Google and AWS have different language lists */
|
||||
/** If the new language doesn't map then default to "en-US" */
|
||||
let newLang = synthesis[vendor].find(
|
||||
(lang) => lang.code === synthLang
|
||||
);
|
||||
|
||||
if (newLang) {
|
||||
setSynthVoice(newLang.voices[0].value);
|
||||
return;
|
||||
}
|
||||
|
||||
newLang = synthesis[vendor].find(
|
||||
(lang) => lang.code === LANG_EN_US
|
||||
);
|
||||
|
||||
setSynthLang(LANG_EN_US);
|
||||
setSynthVoice(newLang!.voices[0].value);
|
||||
}}
|
||||
<fieldset>
|
||||
<Checkzone
|
||||
hidden
|
||||
name="cz_fallback_speech"
|
||||
label="Use a fallback speech vendor if primary fails"
|
||||
initialCheck={initalCheckFallbackSpeech}
|
||||
handleChecked={(e) => {
|
||||
setUseForFallbackSpeech(e.target.checked);
|
||||
}}
|
||||
>
|
||||
<SpeechProviderSelection
|
||||
serviceProviderSid={
|
||||
currentServiceProvider?.service_provider_sid || ""
|
||||
}
|
||||
accountSid={accountSid}
|
||||
credentials={credentials}
|
||||
ttsVendor={[
|
||||
fallbackSpeechSynthsisVendor,
|
||||
setFallbackSpeechSynthsisVendor,
|
||||
]}
|
||||
ttsVendorOptions={ttsVendorOptions}
|
||||
ttsVoice={[
|
||||
fallbackSpeechSynthsisVoice,
|
||||
setFallbackSpeechSynthsisVoice,
|
||||
]}
|
||||
ttsLang={[
|
||||
fallbackSpeechSynthsisLanguage,
|
||||
setFallbackSpeechSynthsisLanguage,
|
||||
]}
|
||||
ttsLabelOptions={fallbackTtsLabelOptions}
|
||||
ttsLabel={[
|
||||
fallbackSpeechSynthsisLabel,
|
||||
setFallbackSpeechSynthsisLabel,
|
||||
]}
|
||||
sttVendor={[
|
||||
fallbackSpeechRecognizerVendor,
|
||||
setFallbackSpeechRecognizerVendor,
|
||||
]}
|
||||
sttVendorOptions={sttVendorOptions}
|
||||
sttLang={[
|
||||
fallbackSpeechRecognizerLanguage,
|
||||
setFallbackSpeechRecognizerLanguage,
|
||||
]}
|
||||
sttLabelOptions={fallbackSttLabelOptions}
|
||||
sttLabel={[
|
||||
fallbackSpeechRecognizerLabel,
|
||||
setFallbackSpeechRecognizerLabel,
|
||||
]}
|
||||
/>
|
||||
{synthVendor && synthLang && (
|
||||
<>
|
||||
<label htmlFor="synthesis_lang">Language</label>
|
||||
<Selector
|
||||
id="synthesis_lang"
|
||||
name="synthesis_lang"
|
||||
value={synthLang}
|
||||
options={synthesis[synthVendor as keyof SynthesisVendors].map(
|
||||
(lang: VoiceLanguage) => ({
|
||||
name: lang.name,
|
||||
value: lang.code,
|
||||
})
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const language = e.target.value;
|
||||
setSynthLang(language);
|
||||
|
||||
/** When using Google and en-US, ensure "Standard-C" is used as default */
|
||||
if (
|
||||
synthVendor === VENDOR_GOOGLE &&
|
||||
language === LANG_EN_US
|
||||
) {
|
||||
setSynthVoice(LANG_EN_US_STANDARD_C);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLang = synthesis[
|
||||
synthVendor as keyof SynthesisVendors
|
||||
].find((lang) => lang.code === language);
|
||||
|
||||
setSynthVoice(newLang!.voices[0].value);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="synthesis_voice">Voice</label>
|
||||
<Selector
|
||||
id="synthesis_voice"
|
||||
name="synthesis_voice"
|
||||
value={synthVoice}
|
||||
options={
|
||||
synthesis[synthVendor as keyof SynthesisVendors]
|
||||
.filter((lang: VoiceLanguage) => lang.code === synthLang)
|
||||
.flatMap((lang: VoiceLanguage) =>
|
||||
lang.voices.map((voice: Voice) => ({
|
||||
name: voice.name,
|
||||
value: voice.value,
|
||||
}))
|
||||
) as Voice[]
|
||||
}
|
||||
onChange={(e) => setSynthVoice(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
{recognizers && (
|
||||
<fieldset>
|
||||
<label htmlFor="recognizer_vendor">Speech recognizer vendor</label>
|
||||
<Selector
|
||||
id="recognizer_vendor"
|
||||
name="recognizer_vendor"
|
||||
value={recogVendor}
|
||||
options={vendors.filter(
|
||||
(vendor) => vendor.value != VENDOR_WELLSAID
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const vendor = e.target.value as keyof RecognizerVendors;
|
||||
setRecogVendor(vendor);
|
||||
|
||||
/** Google and AWS have different language lists */
|
||||
/** If the new language doesn't map then default to "en-US" */
|
||||
const newLang = recognizers[vendor].find(
|
||||
(lang: Language) => lang.code === recogLang
|
||||
);
|
||||
|
||||
if (
|
||||
(vendor === VENDOR_GOOGLE || vendor === VENDOR_AWS) &&
|
||||
!newLang
|
||||
) {
|
||||
setRecogLang(LANG_EN_US);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{recogVendor && recogLang && (
|
||||
<>
|
||||
<label htmlFor="recognizer_lang">Language</label>
|
||||
<Selector
|
||||
id="recognizer_lang"
|
||||
name="recognizer_lang"
|
||||
value={recogLang}
|
||||
options={recognizers[
|
||||
recogVendor as keyof RecognizerVendors
|
||||
].map((lang: Language) => ({
|
||||
name: lang.name,
|
||||
value: lang.code,
|
||||
}))}
|
||||
onChange={(e) => {
|
||||
setRecogLang(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
<fieldset>
|
||||
<Button
|
||||
type="button"
|
||||
small
|
||||
onClick={swapPrimaryAndfalloverSpeech}
|
||||
>
|
||||
Swap primary and fallback
|
||||
</Button>
|
||||
</fieldset>
|
||||
</Checkzone>
|
||||
</fieldset>
|
||||
{(import.meta.env.INITIAL_APP_JSON_ENABLED === undefined ||
|
||||
import.meta.env.INITIAL_APP_JSON_ENABLED) && (
|
||||
<fieldset>
|
||||
@@ -563,6 +811,23 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
</Checkzone>
|
||||
</fieldset>
|
||||
)}
|
||||
{!DISABLE_CALL_RECORDING &&
|
||||
accounts?.filter((a) => a.account_sid === accountSid).length &&
|
||||
!accounts?.filter((a) => a.account_sid === accountSid)[0]
|
||||
.record_all_calls && (
|
||||
<fieldset>
|
||||
<label htmlFor="record_all_call" className="chk">
|
||||
<input
|
||||
id="record_all_call"
|
||||
name="record_all_call"
|
||||
type="checkbox"
|
||||
onChange={(e) => setRecordAllCalls(e.target.checked)}
|
||||
checked={recordAllCalls}
|
||||
/>
|
||||
<div>Record all calls</div>
|
||||
</label>
|
||||
</fieldset>
|
||||
)}
|
||||
{message && <fieldset>{<Message message={message} />}</fieldset>}
|
||||
<fieldset>
|
||||
<ButtonGroup left>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
618
src/containers/internal/views/applications/speech-selection.tsx
Normal file
618
src/containers/internal/views/applications/speech-selection.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
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,
|
||||
VENDOR_OPENAI,
|
||||
} from "src/vendor";
|
||||
import {
|
||||
LabelOptions,
|
||||
RecognizerVendors,
|
||||
SynthesisVendors,
|
||||
VendorOptions,
|
||||
} from "src/vendor/types";
|
||||
type SpeechProviderSelectionProbs = {
|
||||
accountSid: string;
|
||||
serviceProviderSid: string;
|
||||
credentials: SpeechCredential[] | undefined;
|
||||
ttsVendor: [
|
||||
keyof SynthesisVendors,
|
||||
React.Dispatch<React.SetStateAction<keyof SynthesisVendors>>,
|
||||
];
|
||||
ttsVendorOptions: VendorOptions[];
|
||||
ttsVoice: [string, React.Dispatch<React.SetStateAction<string>>];
|
||||
ttsLang: [string, React.Dispatch<React.SetStateAction<string>>];
|
||||
ttsLabelOptions: LabelOptions[];
|
||||
ttsLabel: [string, React.Dispatch<React.SetStateAction<string>>];
|
||||
sttVendor: [
|
||||
keyof RecognizerVendors,
|
||||
React.Dispatch<React.SetStateAction<keyof RecognizerVendors>>,
|
||||
];
|
||||
sttVendorOptions: VendorOptions[];
|
||||
sttLang: [string, React.Dispatch<React.SetStateAction<string>>];
|
||||
sttLabelOptions: LabelOptions[];
|
||||
sttLabel: [string, React.Dispatch<React.SetStateAction<string>>];
|
||||
};
|
||||
|
||||
export const SpeechProviderSelection = ({
|
||||
accountSid,
|
||||
serviceProviderSid,
|
||||
credentials,
|
||||
ttsVendor: [synthVendor, setSynthVendor],
|
||||
ttsVendorOptions,
|
||||
ttsVoice: [synthVoice, setSynthVoice],
|
||||
ttsLang: [synthLang, setSynthLang],
|
||||
ttsLabelOptions,
|
||||
ttsLabel: [synthLabel, setSynthLabel],
|
||||
sttVendor: [recogVendor, setRecogVendor],
|
||||
sttVendorOptions,
|
||||
sttLang: [recogLang, setRecogLang],
|
||||
sttLabelOptions,
|
||||
sttLabel: [recogLabel, setRecogLabel],
|
||||
}: SpeechProviderSelectionProbs) => {
|
||||
const user = useSelectState("user");
|
||||
const [
|
||||
synthesisSupportedLanguagesAndVoices,
|
||||
setSynthesisSupportedLanguagesAndVoices,
|
||||
] = useState<SpeechSupportedLanguagesAndVoices | null>();
|
||||
const [selectedCredential, setSelectedCredential] = useState<
|
||||
SpeechCredential | undefined
|
||||
>();
|
||||
const [synthesisVoiceOptions, setSynthesisVoiceOptions] = useState<
|
||||
SelectorOption[]
|
||||
>([]);
|
||||
const [synthesisLanguageOptions, setSynthesisLanguageOptions] = useState<
|
||||
SelectorOption[]
|
||||
>([]);
|
||||
const [synthesisModelOptions, setSynthesisModelOptions] = useState<
|
||||
SelectorOption[]
|
||||
>([]);
|
||||
const [
|
||||
synthesisGoogleCustomVoiceOptions,
|
||||
setSynthesisGoogleCustomVoiceOptions,
|
||||
] = useState<SelectorOption[]>([]);
|
||||
const [recogLanguageOptions, setRecogLanguageOptions] = useState<
|
||||
SelectorOption[]
|
||||
>([]);
|
||||
|
||||
const currentTtsVendor = useRef(synthVendor);
|
||||
const currentSttVendor = useRef(recogVendor);
|
||||
const shouldUpdateTtsVoice = useRef(false);
|
||||
const shouldUpdateSttLanguage = useRef(false);
|
||||
const ttsEffectTimer = useRef<number | null>(null);
|
||||
const sttEffectTimer = useRef<number | null>(null);
|
||||
|
||||
// Get Synthesis languages and voices
|
||||
useEffect(() => {
|
||||
if (
|
||||
!user ||
|
||||
!synthVendor ||
|
||||
(user?.scope === USER_ADMIN && !serviceProviderSid)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
currentTtsVendor.current = synthVendor;
|
||||
/** When Custom Vendor is used, user you have to input the lange and voice. */
|
||||
if (synthVendor.toString().startsWith(VENDOR_CUSTOM)) {
|
||||
setSynthVoice("");
|
||||
return;
|
||||
}
|
||||
// just execute last change
|
||||
if (ttsEffectTimer.current) {
|
||||
clearTimeout(ttsEffectTimer.current);
|
||||
}
|
||||
|
||||
ttsEffectTimer.current = setTimeout(() => {
|
||||
configSynthesis();
|
||||
}, 200);
|
||||
}, [synthVendor, synthLabel, serviceProviderSid]);
|
||||
|
||||
// Get Recognizer languages and voices
|
||||
useEffect(() => {
|
||||
/** When Custom Vendor is used, user you have to input the lange and voice. */
|
||||
if (recogVendor.toString().startsWith(VENDOR_CUSTOM)) {
|
||||
setRecogLang(LANG_EN_US);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!user ||
|
||||
!recogVendor ||
|
||||
(user?.scope === USER_ADMIN && !serviceProviderSid)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
currentSttVendor.current = recogVendor;
|
||||
// just execute last change
|
||||
if (sttEffectTimer.current) {
|
||||
clearTimeout(sttEffectTimer.current);
|
||||
}
|
||||
|
||||
sttEffectTimer.current = setTimeout(() => {
|
||||
configRecognizer();
|
||||
}, 200);
|
||||
}, [recogVendor, recogLabel, serviceProviderSid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (credentials) {
|
||||
setSelectedCredential(
|
||||
credentials.find(
|
||||
(c) => c.vendor === synthVendor && (c.label || "") === synthLabel,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [synthVendor, synthLabel, credentials]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!synthLabel && ttsLabelOptions?.length > 0) {
|
||||
setSynthLabel(ttsLabelOptions[0].value);
|
||||
}
|
||||
if (!recogLabel && sttLabelOptions?.length > 0) {
|
||||
setRecogLabel(sttLabelOptions[0].value);
|
||||
}
|
||||
}, [ttsLabelOptions, sttLabelOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (synthesisSupportedLanguagesAndVoices) {
|
||||
// Extract Voice
|
||||
const voicesOpts =
|
||||
synthesisSupportedLanguagesAndVoices.tts?.find((lang) => {
|
||||
if (synthVendor === VENDOR_ELEVENLABS && lang.voices.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return lang.value === synthLang;
|
||||
})?.voices || [];
|
||||
|
||||
if (synthVendor === VENDOR_GOOGLE && synthesisGoogleCustomVoiceOptions) {
|
||||
if (synthesisGoogleCustomVoiceOptions) {
|
||||
setSynthesisVoiceOptions([
|
||||
...synthesisGoogleCustomVoiceOptions,
|
||||
...voicesOpts,
|
||||
]);
|
||||
} else {
|
||||
setSynthesisVoiceOptions(voicesOpts);
|
||||
}
|
||||
if (synthesisGoogleCustomVoiceOptions.length > 0) {
|
||||
updateTtsVoice(synthLang, 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))
|
||||
) {
|
||||
updateTtsVoice(LANG_EN_US, LANG_EN_US_STANDARD_C);
|
||||
return;
|
||||
}
|
||||
if (synthVendor === VENDOR_ELEVENLABS) {
|
||||
// Samve Voices applied to all languages
|
||||
// Voices are only available for the 1st language.
|
||||
updateTtsVoice(ELEVENLABS_LANG_EN, json.tts[0].voices[0].value);
|
||||
return;
|
||||
}
|
||||
if (synthVendor === VENDOR_WHISPER) {
|
||||
const newLang = json.tts.find((lang) => lang.value === LANG_EN_US);
|
||||
updateTtsVoice(LANG_EN_US, newLang!.voices[0].value);
|
||||
return;
|
||||
}
|
||||
if (synthVendor === VENDOR_PLAYHT) {
|
||||
const newLang = json.tts.find(
|
||||
(lang) => lang.value === LANG_EN_US || lang.value === "english",
|
||||
);
|
||||
updateTtsVoice(newLang!.value, newLang!.voices[0].value);
|
||||
return;
|
||||
}
|
||||
if (synthVendor === VENDOR_CARTESIA) {
|
||||
const newLang = json.tts.find((lang) => lang.value === "en");
|
||||
updateTtsVoice(newLang!.value, newLang!.voices[0].value);
|
||||
return;
|
||||
}
|
||||
if (synthVendor === VENDOR_RIMELABS) {
|
||||
const newLang = json.tts.find((lang) => lang.value === "eng");
|
||||
updateTtsVoice(newLang!.value, 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(synthLang, newLang.voices[0].value);
|
||||
return;
|
||||
}
|
||||
|
||||
newLang = json.tts.find((lang) => lang.value === LANG_EN_US);
|
||||
|
||||
updateTtsVoice(LANG_EN_US, 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 = (language: string, voice: string) => {
|
||||
if (shouldUpdateTtsVoice.current) {
|
||||
setSynthLang(language);
|
||||
setSynthVoice(voice);
|
||||
shouldUpdateTtsVoice.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const configRecognizer = () => {
|
||||
getSpeechSupportedLanguagesAndVoices(
|
||||
serviceProviderSid,
|
||||
recogVendor,
|
||||
recogLabel,
|
||||
)
|
||||
.then(({ json }) => {
|
||||
// while fetching data, the user might change the vendor
|
||||
if (currentSttVendor.current !== recogVendor) {
|
||||
return;
|
||||
}
|
||||
// Extract Language
|
||||
const langOpts = json.stt.map((lang) => ({
|
||||
name: lang.name,
|
||||
value: lang.value,
|
||||
}));
|
||||
setRecogLanguageOptions(langOpts);
|
||||
|
||||
/**When vendor is custom, Language is input by user */
|
||||
if (
|
||||
recogVendor.toString() === VENDOR_CUSTOM ||
|
||||
!shouldUpdateSttLanguage.current
|
||||
)
|
||||
return;
|
||||
shouldUpdateSttLanguage.current = false;
|
||||
/** Google and AWS have different language lists */
|
||||
/** If the new language doesn't map then default to "en-US" */
|
||||
const newLang = json.stt.find((lang) => lang.value === recogLang);
|
||||
|
||||
if (
|
||||
(recogVendor === VENDOR_GOOGLE || recogVendor === VENDOR_AWS) &&
|
||||
!newLang
|
||||
) {
|
||||
setRecogLang(LANG_EN_US);
|
||||
} else if (recogVendor === VENDOR_COBALT && !newLang) {
|
||||
setRecogLang(LANG_COBALT_EN_US);
|
||||
} else if (langOpts.length && !newLang) {
|
||||
setRecogLang(langOpts[0].value);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<fieldset>
|
||||
<label htmlFor="synthesis_vendor">Speech synthesis vendor</label>
|
||||
<Selector
|
||||
id="synthesis_vendor"
|
||||
name="synthesis_vendor"
|
||||
value={synthVendor}
|
||||
options={ttsVendorOptions.filter(
|
||||
(vendor) =>
|
||||
vendor.value !== VENDOR_ASSEMBLYAI &&
|
||||
vendor.value !== VENDOR_VOXIST &&
|
||||
vendor.value !== VENDOR_SONIOX &&
|
||||
vendor.value !== VENDOR_SPEECHMATICS &&
|
||||
vendor.value !== VENDOR_CUSTOM &&
|
||||
vendor.value !== VENDOR_OPENAI &&
|
||||
vendor.value !== VENDOR_COBALT,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const vendor = e.target.value as keyof SynthesisVendors;
|
||||
shouldUpdateTtsVoice.current = true;
|
||||
setSynthVendor(vendor);
|
||||
setSynthLabel("");
|
||||
setSynthesisLanguageOptions([]);
|
||||
setSynthesisVoiceOptions([]);
|
||||
}}
|
||||
/>
|
||||
{hasLength(ttsLabelOptions) && (
|
||||
<>
|
||||
<label htmlFor="synthesis_label">Label</label>
|
||||
<Selector
|
||||
id="systhesis_label"
|
||||
name="systhesis_label"
|
||||
value={synthLabel}
|
||||
options={ttsLabelOptions}
|
||||
onChange={(e) => {
|
||||
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="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) => {
|
||||
shouldUpdateTtsVoice.current = true;
|
||||
const language = e.target.value;
|
||||
setSynthLang(language);
|
||||
|
||||
/** When using Google and en-US, ensure "Standard-C" is used as default */
|
||||
if (
|
||||
synthVendor === VENDOR_GOOGLE &&
|
||||
language === LANG_EN_US
|
||||
) {
|
||||
setSynthVoice(LANG_EN_US_STANDARD_C);
|
||||
return;
|
||||
}
|
||||
|
||||
const voices =
|
||||
synthesisSupportedLanguagesAndVoices?.tts.find(
|
||||
(lang) => lang.value === language,
|
||||
)?.voices || [];
|
||||
if (
|
||||
synthVendor === VENDOR_GOOGLE &&
|
||||
synthesisGoogleCustomVoiceOptions &&
|
||||
synthesisGoogleCustomVoiceOptions.length
|
||||
) {
|
||||
setSynthesisVoiceOptions([
|
||||
...synthesisGoogleCustomVoiceOptions,
|
||||
...voices,
|
||||
]);
|
||||
} else {
|
||||
setSynthesisVoiceOptions(voices);
|
||||
}
|
||||
|
||||
setSynthVoice(voices[0].value);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="synthesis_voice">Voice</label>
|
||||
{synthVendor === VENDOR_MICROSOFT &&
|
||||
selectedCredential &&
|
||||
selectedCredential.use_custom_tts ? (
|
||||
<input
|
||||
id="custom_microsoft_synthesis_voice"
|
||||
type="text"
|
||||
name="custom_microsoft_synthesis_voice"
|
||||
placeholder="Required"
|
||||
required
|
||||
value={synthVoice}
|
||||
onChange={(e) => {
|
||||
setSynthVoice(e.target.value);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Selector
|
||||
id="synthesis_voice"
|
||||
name="synthesis_voice"
|
||||
value={synthVoice}
|
||||
options={synthesisVoiceOptions.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="recognizer_lang">Language</label>
|
||||
<Selector
|
||||
id="recognizer_lang"
|
||||
name="recognizer_lang"
|
||||
value={recogLang}
|
||||
options={recogLanguageOptions}
|
||||
onChange={(e) => {
|
||||
setRecogLang(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{recogVendor.toString().startsWith(VENDOR_CUSTOM) && (
|
||||
<>
|
||||
<label htmlFor="custom_vendor_recognizer_voice">Language</label>
|
||||
<input
|
||||
id="custom_vendor_recognizer_voice"
|
||||
type="text"
|
||||
name="custom_vendor_recognizer_voice"
|
||||
placeholder="Required"
|
||||
required
|
||||
value={recogLang}
|
||||
onChange={(e) => {
|
||||
setRecogLang(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeechProviderSelection;
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -19,15 +19,18 @@ import {
|
||||
import {
|
||||
DEFAULT_SIP_GATEWAY,
|
||||
DEFAULT_SMPP_GATEWAY,
|
||||
DTMF_TYPE_SELECTION,
|
||||
FQDN,
|
||||
FQDN_TOP_LEVEL,
|
||||
INVALID,
|
||||
IP,
|
||||
NETMASK_OPTIONS,
|
||||
SIP_GATEWAY_PROTOCOL_OPTIONS,
|
||||
TCP_MAX_PORT,
|
||||
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,
|
||||
@@ -45,20 +48,25 @@ import {
|
||||
isUserAccountScope,
|
||||
hasLength,
|
||||
isValidPort,
|
||||
disableDefaultTrunkRouting,
|
||||
hasValue,
|
||||
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";
|
||||
|
||||
type CarrierFormProps = {
|
||||
carrier?: UseApiDataMap<Carrier>;
|
||||
@@ -95,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("");
|
||||
@@ -110,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("");
|
||||
@@ -133,8 +145,47 @@ 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);
|
||||
if (obj.name) {
|
||||
setCarrierName(obj.name);
|
||||
}
|
||||
@@ -197,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);
|
||||
}
|
||||
@@ -209,6 +267,9 @@ export const CarrierForm = ({
|
||||
if (obj.smpp_inbound_password) {
|
||||
setSmppInboundPass(obj.smpp_inbound_password);
|
||||
}
|
||||
if (obj.dtmf_type) {
|
||||
setDtmfType(obj.dtmf_type);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -229,20 +290,33 @@ export const CarrierForm = ({
|
||||
const updateSipGateways = (
|
||||
index: number,
|
||||
key: string,
|
||||
value: typeof sipGateways[number][keyof SipGateway]
|
||||
value: (typeof sipGateways)[number][keyof SipGateway],
|
||||
) => {
|
||||
setSipGateways(
|
||||
sipGateways.map((g, i) => (i === index ? { ...g, [key]: value } : g))
|
||||
sipGateways.map((g, i) =>
|
||||
i === index
|
||||
? {
|
||||
...g,
|
||||
[key]: value,
|
||||
// If Change to ipv4 and port is null, change port to 5060
|
||||
...(key === "ipv4" &&
|
||||
value &&
|
||||
typeof value === "string" &&
|
||||
getIpValidationType(value) === IP &&
|
||||
g.port === null && { port: 5060 }),
|
||||
}
|
||||
: g,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const updateSmppGateways = (
|
||||
index: number,
|
||||
key: string,
|
||||
value: typeof smppGateways[number][keyof SmppGateway]
|
||||
value: (typeof smppGateways)[number][keyof SmppGateway],
|
||||
) => {
|
||||
setSmppGateways(
|
||||
smppGateways.map((g, i) => (i === index ? { ...g, [key]: value } : g))
|
||||
smppGateways.map((g, i) => (i === index ? { ...g, [key]: value } : g)),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -251,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();
|
||||
@@ -269,7 +343,7 @@ export const CarrierForm = ({
|
||||
smpp_gateway_sid
|
||||
? putSmppGateway(smpp_gateway_sid, g)
|
||||
: postSmppGateway({ ...g, voip_carrier_sid });
|
||||
})
|
||||
}),
|
||||
).then(() => {
|
||||
if (carrierSmppGateways) {
|
||||
carrierSmppGateways.refetch();
|
||||
@@ -280,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"),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -291,8 +365,8 @@ export const CarrierForm = ({
|
||||
toastSuccess(
|
||||
`SMPP ${
|
||||
g.outbound ? "outbound" : "inbound"
|
||||
} gateway successfully deleted`
|
||||
)
|
||||
} gateway successfully deleted`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -316,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) {
|
||||
@@ -405,7 +482,9 @@ export const CarrierForm = ({
|
||||
/** When to switch to `sip` tab */
|
||||
|
||||
const emptySipIp = sipGateways.find((g) => g.ipv4.trim() === "");
|
||||
const invalidSipPort = sipGateways.find((g) => !isValidPort(g.port));
|
||||
const invalidSipPort = sipGateways.find(
|
||||
(g) => hasValue(g.port) && !isValidPort(g.port),
|
||||
);
|
||||
const sipGatewayValidation = getSipValidation();
|
||||
|
||||
/** Empty SIP gateway */
|
||||
@@ -477,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(),
|
||||
@@ -499,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) {
|
||||
@@ -516,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) => {
|
||||
@@ -546,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) {
|
||||
@@ -554,11 +643,11 @@ export const CarrierForm = ({
|
||||
user?.scope === USER_ACCOUNT
|
||||
? postPredefinedCarrierTemplateAccount(
|
||||
accountSid,
|
||||
predefinedCarrierSid
|
||||
predefinedCarrierSid,
|
||||
)
|
||||
: postPredefinedCarrierTemplate(
|
||||
currentServiceProvider.service_provider_sid,
|
||||
predefinedCarrierSid
|
||||
predefinedCarrierSid,
|
||||
);
|
||||
|
||||
postPredefinedCarrier
|
||||
@@ -614,10 +703,24 @@ 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>
|
||||
{carrier &&
|
||||
carrier.data &&
|
||||
Boolean(carrier.data.requires_register) &&
|
||||
carrier.data.register_status && (
|
||||
<fieldset>
|
||||
<div className="m med">Register status</div>
|
||||
<RegisterStatus carrier={carrier.data} />
|
||||
</fieldset>
|
||||
)}
|
||||
<fieldset>
|
||||
<div className="multi">
|
||||
<div className="inp">
|
||||
@@ -654,9 +757,9 @@ export const CarrierForm = ({
|
||||
(carrier: PredefinedCarrier) => ({
|
||||
name: carrier.name,
|
||||
value: carrier.name,
|
||||
})
|
||||
}),
|
||||
)
|
||||
: []
|
||||
: [],
|
||||
)}
|
||||
onChange={(e) => setPredefinedName(e.target.value)}
|
||||
/>
|
||||
@@ -709,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
|
||||
}
|
||||
@@ -721,22 +824,42 @@ export const CarrierForm = ({
|
||||
user?.scope !== USER_ACCOUNT
|
||||
? false
|
||||
: user.account_sid !== accountSid
|
||||
? true
|
||||
: false
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
{accountSid && hasLength(applications) && (
|
||||
<>
|
||||
<ApplicationSelect
|
||||
label="Default Application"
|
||||
defaultOption="None"
|
||||
application={[applicationSid, setApplicationSid]}
|
||||
applications={applications.filter(
|
||||
(application) => application.account_sid === accountSid
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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 &&
|
||||
hasLength(applications) && (
|
||||
<>
|
||||
<ApplicationSelect
|
||||
label="Default Application"
|
||||
defaultOption="None"
|
||||
application={[applicationSid, setApplicationSid]}
|
||||
applications={applications.filter(
|
||||
(application) => application.account_sid === accountSid,
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<Checkzone
|
||||
@@ -760,7 +883,7 @@ export const CarrierForm = ({
|
||||
Does your carrier require authentication on outbound calls?
|
||||
</MS>
|
||||
<label htmlFor="sip_username">
|
||||
Username {sipPass || sipRegister ? <span>*</span> : ""}
|
||||
Auth username {sipPass || sipRegister ? <span>*</span> : ""}
|
||||
</label>
|
||||
<input
|
||||
id="sip_username"
|
||||
@@ -815,7 +938,7 @@ export const CarrierForm = ({
|
||||
required={sipRegister}
|
||||
onChange={(e) => setSipRealm(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="from_user">SIP from user</label>
|
||||
<label htmlFor="from_user">Username</label>
|
||||
<input
|
||||
id="from_user"
|
||||
name="from_user"
|
||||
@@ -905,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>
|
||||
@@ -946,13 +1096,21 @@ export const CarrierForm = ({
|
||||
type="number"
|
||||
min="0"
|
||||
max={TCP_MAX_PORT}
|
||||
placeholder={DEFAULT_SIP_GATEWAY.port.toString()}
|
||||
value={g.port}
|
||||
placeholder={
|
||||
g.protocol === "tls" || g.protocol === "tls/srtp"
|
||||
? ""
|
||||
: DEFAULT_SIP_GATEWAY.port?.toString()
|
||||
}
|
||||
value={g.port === null ? "" : g.port}
|
||||
onChange={(e) => {
|
||||
updateSipGateways(
|
||||
i,
|
||||
"port",
|
||||
Number(e.target.value)
|
||||
g.outbound > 0 &&
|
||||
!isNotBlank(e.target.value) &&
|
||||
getIpValidationType(g.ipv4) !== IP
|
||||
? null
|
||||
: Number(e.target.value),
|
||||
);
|
||||
}}
|
||||
ref={(ref: HTMLInputElement) =>
|
||||
@@ -960,20 +1118,54 @@ export const CarrierForm = ({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Selector
|
||||
id={`sip_netmask_${i}`}
|
||||
name={`sip_netmask${i}`}
|
||||
placeholder="32"
|
||||
value={g.netmask}
|
||||
options={NETMASK_OPTIONS}
|
||||
onChange={(e) => {
|
||||
updateSipGateways(i, "netmask", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{g.outbound ? (
|
||||
<div>
|
||||
<Selector
|
||||
id={`sip_protocol_${i}`}
|
||||
name={`sip_protocol${i}`}
|
||||
value={g.protocol}
|
||||
options={SIP_GATEWAY_PROTOCOL_OPTIONS}
|
||||
onChange={(e) => {
|
||||
updateSipGateways(i, "protocol", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Selector
|
||||
id={`sip_netmask_${i}`}
|
||||
name={`sip_netmask${i}`}
|
||||
value={g.netmask}
|
||||
options={NETMASK_OPTIONS}
|
||||
onChange={(e) => {
|
||||
updateSipGateways(i, "netmask", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`sip__gw_is_active_${i}`}
|
||||
className="chk"
|
||||
>
|
||||
<input
|
||||
id={`sip__gw_is_active_${i}`}
|
||||
name={`sip__gw_is_active_${i}`}
|
||||
type="checkbox"
|
||||
checked={g.is_active ? true : false}
|
||||
onChange={(e) => {
|
||||
updateSipGateways(
|
||||
i,
|
||||
"is_active",
|
||||
e.target.checked ? 1 : 0,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div>Active</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`sip_inbound_${i}`} className="chk">
|
||||
<input
|
||||
@@ -986,7 +1178,7 @@ export const CarrierForm = ({
|
||||
updateSipGateways(
|
||||
i,
|
||||
"inbound",
|
||||
e.target.checked ? 1 : 0
|
||||
e.target.checked ? 1 : 0,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -1005,13 +1197,78 @@ export const CarrierForm = ({
|
||||
updateSipGateways(
|
||||
i,
|
||||
"outbound",
|
||||
e.target.checked
|
||||
e.target.checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div>Outbound</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`sip_pad_crypto_${i}`} className="chk">
|
||||
<input
|
||||
id={`sip_pad_crypto_${i}`}
|
||||
name={`sip_pad_crypto_${i}`}
|
||||
type="checkbox"
|
||||
checked={g.pad_crypto ? true : false}
|
||||
onChange={(e) => {
|
||||
updateSipGateways(
|
||||
i,
|
||||
"pad_crypto",
|
||||
e.target.checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div>Pad crypto</div>
|
||||
</label>
|
||||
</div>
|
||||
{Boolean(g.outbound) && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`send_options_ping_${i}`}
|
||||
className="chk"
|
||||
>
|
||||
<input
|
||||
id={`send_options_ping_${i}`}
|
||||
name={`send_options_ping_${i}`}
|
||||
type="checkbox"
|
||||
checked={g.send_options_ping ? true : false}
|
||||
onChange={(e) => {
|
||||
updateSipGateways(
|
||||
i,
|
||||
"send_options_ping",
|
||||
e.target.checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div>Send OPTIONS ping</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{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
|
||||
@@ -1023,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),
|
||||
);
|
||||
}
|
||||
}}
|
||||
@@ -1145,7 +1402,7 @@ export const CarrierForm = ({
|
||||
updateSmppGateways(
|
||||
i,
|
||||
"port",
|
||||
Number(e.target.value)
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
ref={(ref: HTMLInputElement) =>
|
||||
@@ -1164,7 +1421,7 @@ export const CarrierForm = ({
|
||||
updateSmppGateways(
|
||||
i,
|
||||
"use_tls",
|
||||
e.target.checked
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -1186,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),
|
||||
);
|
||||
}
|
||||
}}
|
||||
@@ -1288,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) =>
|
||||
@@ -1303,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),
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -29,16 +29,25 @@ import {
|
||||
import {
|
||||
API_SIP_GATEWAY,
|
||||
API_SMPP_GATEWAY,
|
||||
CARRIER_REG_OK,
|
||||
ENABLE_HOSTED_SYSTEM,
|
||||
USER_ACCOUNT,
|
||||
} from "src/api/constants";
|
||||
import { DeleteCarrier } from "./delete";
|
||||
|
||||
import type { Account, Carrier, SipGateway, SmppGateway } from "src/api/types";
|
||||
import type {
|
||||
Account,
|
||||
Carrier,
|
||||
CurrentUserData,
|
||||
SipGateway,
|
||||
SmppGateway,
|
||||
} from "src/api/types";
|
||||
import { Scope } from "src/store/types";
|
||||
import { getAccountFilter, setLocation } from "src/store/localStore";
|
||||
|
||||
export const Carriers = () => {
|
||||
const user = useSelectState("user");
|
||||
const [userData] = useApiData<CurrentUserData>("Users/me");
|
||||
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [carrier, setCarrier] = useState<Carrier | null>(null);
|
||||
@@ -51,21 +60,20 @@ export const Carriers = () => {
|
||||
setAccountSid(getAccountFilter());
|
||||
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
|
||||
setAccountSid(user?.account_sid);
|
||||
return carriers;
|
||||
}
|
||||
|
||||
return carriers
|
||||
? carriers.filter((carrier) =>
|
||||
accountSid
|
||||
? carrier.account_sid === accountSid
|
||||
: carrier.account_sid === null
|
||||
: carrier.account_sid === null,
|
||||
)
|
||||
: [];
|
||||
}, [accountSid, carrier, carriers]);
|
||||
|
||||
const filteredCarriers = useFilteredResults<Carrier>(
|
||||
filter,
|
||||
carriersFiltered
|
||||
carriersFiltered,
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
@@ -79,10 +87,10 @@ export const Carriers = () => {
|
||||
.then(() => {
|
||||
Promise.all([
|
||||
getFetch<SipGateway[]>(
|
||||
`${API_SIP_GATEWAY}?voip_carrier_sid=${carrier.voip_carrier_sid}`
|
||||
`${API_SIP_GATEWAY}?voip_carrier_sid=${carrier.voip_carrier_sid}`,
|
||||
),
|
||||
getFetch<SmppGateway[]>(
|
||||
`${API_SMPP_GATEWAY}?voip_carrier_sid=${carrier.voip_carrier_sid}`
|
||||
`${API_SMPP_GATEWAY}?voip_carrier_sid=${carrier.voip_carrier_sid}`,
|
||||
),
|
||||
]).then(([sipGatewaysRes, smppGatewaysRes]) => {
|
||||
hasLength(sipGatewaysRes.json) &&
|
||||
@@ -91,8 +99,8 @@ export const Carriers = () => {
|
||||
g &&
|
||||
g.sip_gateway_sid &&
|
||||
deleteSipGateway(g.sip_gateway_sid).catch((error) =>
|
||||
toastError(error.msg)
|
||||
)
|
||||
toastError(error.msg),
|
||||
),
|
||||
);
|
||||
hasLength(smppGatewaysRes.json) &&
|
||||
smppGatewaysRes.json.forEach(
|
||||
@@ -100,8 +108,8 @@ export const Carriers = () => {
|
||||
g &&
|
||||
g.smpp_gateway_sid &&
|
||||
deleteSmppGateway(g.smpp_gateway_sid).catch((error) =>
|
||||
toastError(error.msg)
|
||||
)
|
||||
toastError(error.msg),
|
||||
),
|
||||
);
|
||||
});
|
||||
setCarrier(null);
|
||||
@@ -109,7 +117,7 @@ export const Carriers = () => {
|
||||
toastSuccess(
|
||||
<>
|
||||
Deleted Carrier <strong>{carrier.name}</strong>
|
||||
</>
|
||||
</>,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -122,7 +130,7 @@ export const Carriers = () => {
|
||||
setLocation();
|
||||
if (currentServiceProvider) {
|
||||
setApiUrl(
|
||||
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`
|
||||
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`,
|
||||
);
|
||||
}
|
||||
}, [user, currentServiceProvider, accountSid]);
|
||||
@@ -130,7 +138,16 @@ export const Carriers = () => {
|
||||
return (
|
||||
<>
|
||||
<section className="mast">
|
||||
<H1 className="h2">Carriers</H1>
|
||||
<div>
|
||||
<H1 className="h2">Carriers</H1>
|
||||
{ENABLE_HOSTED_SYSTEM && (
|
||||
<M>
|
||||
Have your carrier send calls to{" "}
|
||||
<span>{userData?.account?.sip_realm}</span>
|
||||
</M>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link to={`${ROUTE_INTERNAL_CARRIERS}/add`} title="Add a Carrier">
|
||||
{" "}
|
||||
<Icon>
|
||||
@@ -138,7 +155,7 @@ export const Carriers = () => {
|
||||
</Icon>
|
||||
</Link>
|
||||
</section>
|
||||
<section className="filters filters--spaced">
|
||||
<section className="filters filters--multi">
|
||||
<SearchFilter
|
||||
placeholder="Filter carriers"
|
||||
filter={[filter, setFilter]}
|
||||
@@ -197,6 +214,26 @@ export const Carriers = () => {
|
||||
<span>{carrier.is_active ? "Active" : "Inactive"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{Boolean(carrier.requires_register) && (
|
||||
<div
|
||||
className={`i txt--${
|
||||
carrier.register_status.status === CARRIER_REG_OK
|
||||
? "teal"
|
||||
: "jam"
|
||||
}`}
|
||||
>
|
||||
{carrier.register_status.status === CARRIER_REG_OK ? (
|
||||
<Icons.CheckCircle />
|
||||
) : (
|
||||
<Icons.XCircle />
|
||||
)}
|
||||
<span>
|
||||
{carrier.register_status.status === CARRIER_REG_OK
|
||||
? "Registered"
|
||||
: "Unregistered"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Gateways carrier={carrier} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
65
src/containers/internal/views/carriers/pcap.tsx
Normal file
65
src/containers/internal/views/carriers/pcap.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
getRecentCall,
|
||||
getServiceProviderRecentCall,
|
||||
getPcap,
|
||||
getServiceProviderPcap,
|
||||
} from "src/api";
|
||||
import { toastError } from "src/store";
|
||||
|
||||
import type { DownloadedBlob } from "src/api/types";
|
||||
|
||||
type PcapButtonProps = {
|
||||
accountSid: string;
|
||||
serviceProviderSid: string;
|
||||
sipCallId: string;
|
||||
};
|
||||
|
||||
export const PcapButton = ({
|
||||
accountSid,
|
||||
serviceProviderSid,
|
||||
sipCallId,
|
||||
}: PcapButtonProps) => {
|
||||
const [pcap, setPcap] = useState<DownloadedBlob>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sipCallId) return;
|
||||
const p = accountSid
|
||||
? getRecentCall(accountSid, sipCallId)
|
||||
: getServiceProviderRecentCall(serviceProviderSid, sipCallId);
|
||||
p.then(({ json }) => {
|
||||
if (json.total > 0) {
|
||||
const p1 = accountSid
|
||||
? getPcap(accountSid, sipCallId, "register")
|
||||
: getServiceProviderPcap(serviceProviderSid, sipCallId, "register");
|
||||
p1.then(({ blob }) => {
|
||||
if (blob) {
|
||||
setPcap({
|
||||
data_url: URL.createObjectURL(blob),
|
||||
file_name: `callid-${sipCallId}.pcap`,
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (pcap) {
|
||||
return (
|
||||
<a
|
||||
href={pcap.data_url}
|
||||
download={pcap.file_name}
|
||||
className="btn btn--small pcap"
|
||||
>
|
||||
Download pcap
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
59
src/containers/internal/views/carriers/register-status.tsx
Normal file
59
src/containers/internal/views/carriers/register-status.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import { Carrier } from "src/api/types";
|
||||
import { Icons } from "src/components";
|
||||
import { CARRIER_REG_OK } from "src/api/constants";
|
||||
import { MS } from "@jambonz/ui-kit";
|
||||
import { PcapButton } from "./pcap";
|
||||
|
||||
type CarrierProps = {
|
||||
carrier: Carrier;
|
||||
};
|
||||
|
||||
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
|
||||
className={`i txt--${
|
||||
carrier.register_status.status
|
||||
? carrier.register_status.status === CARRIER_REG_OK
|
||||
? "teal"
|
||||
: "jam"
|
||||
: "jean"
|
||||
}`}
|
||||
title={getReason()}
|
||||
>
|
||||
{carrier.register_status.status === CARRIER_REG_OK ? (
|
||||
<Icons.CheckCircle />
|
||||
) : (
|
||||
<Icons.XCircle />
|
||||
)}
|
||||
<span>
|
||||
{carrier.register_status.status
|
||||
? `Status ${carrier.register_status.status}`
|
||||
: "Not Started"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<details className={carrier.register_status.status || "not-tested"}>
|
||||
<summary>{renderStatus()}</summary>
|
||||
<MS>
|
||||
<strong>Reason:</strong> {getReason()}
|
||||
</MS>
|
||||
<PcapButton
|
||||
accountSid={carrier.account_sid || ""}
|
||||
serviceProviderSid={carrier.service_provider_sid}
|
||||
sipCallId={carrier.register_status.callId || ""}
|
||||
/>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
14
src/containers/internal/views/clients/add.tsx
Normal file
14
src/containers/internal/views/clients/add.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { H1 } from "@jambonz/ui-kit";
|
||||
import React from "react";
|
||||
import ClientsForm from "./form";
|
||||
|
||||
export const ClientsAdd = () => {
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Add sip client</H1>
|
||||
<ClientsForm />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsAdd;
|
||||
28
src/containers/internal/views/clients/delete.tsx
Normal file
28
src/containers/internal/views/clients/delete.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { P } from "@jambonz/ui-kit";
|
||||
import React from "react";
|
||||
import { Client } from "src/api/types";
|
||||
import { Modal } from "src/components";
|
||||
|
||||
type ClientsDeleteProps = {
|
||||
client: Client;
|
||||
handleCancel: () => void;
|
||||
handleSubmit: () => void;
|
||||
};
|
||||
export const ClientsDelete = ({
|
||||
client,
|
||||
handleCancel,
|
||||
handleSubmit,
|
||||
}: ClientsDeleteProps) => {
|
||||
return (
|
||||
<>
|
||||
<Modal handleCancel={handleCancel} handleSubmit={handleSubmit}>
|
||||
<P>
|
||||
Are you sure you want to delete the sip client{" "}
|
||||
<strong>{client.username}</strong>?
|
||||
</P>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsDelete;
|
||||
33
src/containers/internal/views/clients/edit.tsx
Normal file
33
src/containers/internal/views/clients/edit.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { H1 } from "@jambonz/ui-kit";
|
||||
import React, { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useApiData } from "src/api";
|
||||
import { Client } from "src/api/types";
|
||||
import { toastError } from "src/store";
|
||||
import ClientsForm from "./form";
|
||||
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
|
||||
|
||||
export const ClientsEdit = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [data, refetch, error] = useApiData<Client>(
|
||||
`Clients/${params.client_sid}`,
|
||||
);
|
||||
|
||||
/** Handle error toast at top level... */
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toastError(error.msg);
|
||||
navigate(ROUTE_INTERNAL_CLIENTS);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Edit sip client</H1>
|
||||
<ClientsForm client={{ data, refetch, error }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsEdit;
|
||||
272
src/containers/internal/views/clients/form.tsx
Normal file
272
src/containers/internal/views/clients/form.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
deleteClient,
|
||||
postClient,
|
||||
putClient,
|
||||
useServiceProviderData,
|
||||
} from "src/api";
|
||||
import { USER_ACCOUNT } from "src/api/constants";
|
||||
import { Account, Client, UseApiDataMap } from "src/api/types";
|
||||
import { Section, Tooltip } from "src/components";
|
||||
import { AccountSelect, Message, Passwd } from "src/components/forms";
|
||||
import { MSG_REQUIRED_FIELDS } from "src/constants";
|
||||
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
|
||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||
import ClientsDelete from "./delete";
|
||||
import { hasValue } from "src/utils";
|
||||
import { IMessage } from "src/store/types";
|
||||
|
||||
type ClientsFormProps = {
|
||||
client?: UseApiDataMap<Client>;
|
||||
};
|
||||
|
||||
export const ClientsForm = ({ client }: ClientsFormProps) => {
|
||||
const user = useSelectState("user");
|
||||
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [isActive, setIsActive] = useState(
|
||||
client ? client.data?.is_active : true,
|
||||
);
|
||||
const [allowDirectAppCalling, setAllowDirectAppCalling] = useState(true);
|
||||
const [allowDirectQueueCalling, setAllowDirectQueueCalling] = useState(true);
|
||||
const [allowDirectUserCalling, setAllowDirectUserCalling] = useState(true);
|
||||
const [modal, setModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!client) {
|
||||
postClient({
|
||||
account_sid: accountSid,
|
||||
username: username,
|
||||
password: password,
|
||||
is_active: isActive,
|
||||
allow_direct_app_calling: allowDirectAppCalling,
|
||||
allow_direct_queue_calling: allowDirectQueueCalling,
|
||||
allow_direct_user_calling: allowDirectUserCalling,
|
||||
})
|
||||
.then(() => {
|
||||
toastSuccess("Client created successfully");
|
||||
navigate(ROUTE_INTERNAL_CLIENTS);
|
||||
})
|
||||
.catch((error: { msg: IMessage }) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
} else {
|
||||
putClient(client.data?.client_sid || "", {
|
||||
is_active: isActive,
|
||||
allow_direct_app_calling: allowDirectAppCalling,
|
||||
allow_direct_queue_calling: allowDirectQueueCalling,
|
||||
allow_direct_user_calling: allowDirectUserCalling,
|
||||
})
|
||||
.then(() => {
|
||||
toastSuccess("Client updated successfully");
|
||||
navigate(ROUTE_INTERNAL_CLIENTS);
|
||||
})
|
||||
.catch((error: { msg: IMessage }) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setModal(false);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (client) {
|
||||
deleteClient(client.data?.client_sid || "")
|
||||
.then(() => {
|
||||
toastSuccess("Client deleted successfully");
|
||||
navigate(ROUTE_INTERNAL_CLIENTS);
|
||||
})
|
||||
.catch((error: { msg: IMessage }) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (client && client.data) {
|
||||
if (client.data.username) {
|
||||
setUsername(client.data.username);
|
||||
}
|
||||
|
||||
if (client.data.account_sid) {
|
||||
setAccountSid(client.data.account_sid);
|
||||
}
|
||||
|
||||
if (client.data.password) {
|
||||
setPassword(client.data.password);
|
||||
}
|
||||
|
||||
setIsActive(client.data.is_active);
|
||||
setAllowDirectAppCalling(client.data.allow_direct_app_calling);
|
||||
setAllowDirectQueueCalling(client.data.allow_direct_queue_calling);
|
||||
setAllowDirectUserCalling(client.data.allow_direct_user_calling);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
const acc = accounts?.find((a) => a.account_sid === accountSid);
|
||||
if (!accountSid || !accounts || !acc) return;
|
||||
if (!acc?.sip_realm) {
|
||||
setErrorMessage(`Sip realm is not set for the account.`);
|
||||
} else {
|
||||
setErrorMessage("");
|
||||
}
|
||||
}, [accountSid]);
|
||||
return (
|
||||
<>
|
||||
<Section slim>
|
||||
<form
|
||||
className={`form form--internal ${
|
||||
!client?.data && client?.refetch ? "form--blur" : ""
|
||||
}`}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<fieldset>
|
||||
<MS>{MSG_REQUIRED_FIELDS}</MS>
|
||||
{errorMessage && <Message message={errorMessage} />}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<div className="multi">
|
||||
<div className="inp">
|
||||
<label htmlFor="lcr_name">
|
||||
User Name<span>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="client_username"
|
||||
name="client_username"
|
||||
type="text"
|
||||
placeholder="user name"
|
||||
value={username}
|
||||
required={true}
|
||||
disabled={hasValue(client)}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label htmlFor="password">
|
||||
Password{!hasValue(client) && <span>*</span>}
|
||||
</label>
|
||||
<Passwd
|
||||
id="password"
|
||||
required={!hasValue(client)}
|
||||
name="password"
|
||||
value={password}
|
||||
placeholder="Password"
|
||||
setValue={setPassword}
|
||||
disabled={hasValue(client)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<label htmlFor="is_active" className="chk">
|
||||
<input
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
/>
|
||||
<div>Active</div>
|
||||
</label>
|
||||
<label htmlFor="allow_direct_app_calling" className="chk">
|
||||
<input
|
||||
id="allow_direct_app_calling"
|
||||
name="allow_direct_app_calling"
|
||||
type="checkbox"
|
||||
checked={allowDirectAppCalling}
|
||||
onChange={(e) => setAllowDirectAppCalling(e.target.checked)}
|
||||
/>
|
||||
<div>Allow direct calling to applications</div>
|
||||
<Tooltip text="Allow user to call applications without configuring an application for sip device calls.">
|
||||
{" "}
|
||||
</Tooltip>
|
||||
</label>
|
||||
<label htmlFor="allow_direct_queue_calling" className="chk">
|
||||
<input
|
||||
id="allow_direct_queue_calling"
|
||||
name="allow_direct_queue_calling"
|
||||
type="checkbox"
|
||||
checked={allowDirectQueueCalling}
|
||||
onChange={(e) => setAllowDirectQueueCalling(e.target.checked)}
|
||||
/>
|
||||
<div>Allow direct calling to queues</div>
|
||||
<Tooltip text="Allow user to take calls from queues without configuring an application for sip device calls.">
|
||||
{" "}
|
||||
</Tooltip>
|
||||
</label>
|
||||
<label htmlFor="allow_direct_user_calling" className="chk">
|
||||
<input
|
||||
id="allow_direct_user_calling"
|
||||
name="allow_direct_user_calling"
|
||||
type="checkbox"
|
||||
checked={allowDirectUserCalling}
|
||||
onChange={(e) => setAllowDirectUserCalling(e.target.checked)}
|
||||
/>
|
||||
<div>Allow direct calling to other users</div>
|
||||
<Tooltip text="Allow user to call other users without configuring an application for sip device calls.">
|
||||
{" "}
|
||||
</Tooltip>
|
||||
</label>
|
||||
</fieldset>
|
||||
{user?.scope !== USER_ACCOUNT && (
|
||||
<fieldset>
|
||||
<AccountSelect
|
||||
accounts={accounts}
|
||||
account={[accountSid, setAccountSid]}
|
||||
label="Belongs to"
|
||||
required={true}
|
||||
defaultOption={false}
|
||||
disabled={hasValue(client)}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
<fieldset>
|
||||
<ButtonGroup left className={client && "btns--spaced"}>
|
||||
<Button
|
||||
small
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={ROUTE_INTERNAL_CLIENTS}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" small disabled={errorMessage !== ""}>
|
||||
Save
|
||||
</Button>
|
||||
{client && client.data && (
|
||||
<Button
|
||||
small
|
||||
type="button"
|
||||
subStyle="grey"
|
||||
onClick={() => setModal(true)}
|
||||
>
|
||||
Delete User
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Section>
|
||||
{client && client.data && modal && (
|
||||
<ClientsDelete
|
||||
client={client.data}
|
||||
handleCancel={handleCancel}
|
||||
handleSubmit={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsForm;
|
||||
229
src/containers/internal/views/clients/index.tsx
Normal file
229
src/containers/internal/views/clients/index.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Button, H1, Icon, M } from "@jambonz/ui-kit";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { deleteClient, useApiData, useServiceProviderData } from "src/api";
|
||||
import { Account, Client, CurrentUserData } from "src/api/types";
|
||||
import {
|
||||
AccountFilter,
|
||||
Icons,
|
||||
ScopedAccess,
|
||||
SearchFilter,
|
||||
Section,
|
||||
Spinner,
|
||||
} from "src/components";
|
||||
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
|
||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||
import { Scope } from "src/store/types";
|
||||
import { hasLength, hasValue, useFilteredResults } from "src/utils";
|
||||
import ClientsDelete from "./delete";
|
||||
import { USER_ACCOUNT } from "src/api/constants";
|
||||
|
||||
export const Clients = () => {
|
||||
const user = useSelectState("user");
|
||||
const [userData] = useApiData<CurrentUserData>("Users/me");
|
||||
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||
const [clients, refetch] = useApiData<Client[]>("Clients");
|
||||
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
const [selectedAccount, setSelectedAccount] = useState<
|
||||
Account | null | undefined
|
||||
>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [client, setClient] = useState<Client | null>();
|
||||
|
||||
const tmpFilteredClients = useMemo(() => {
|
||||
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
|
||||
setAccountSid(user?.account_sid);
|
||||
return clients;
|
||||
}
|
||||
|
||||
setSelectedAccount(
|
||||
accountSid
|
||||
? accounts?.find((a: Account) => a.account_sid === accountSid)
|
||||
: null,
|
||||
);
|
||||
|
||||
return clients
|
||||
? clients.filter((c) => {
|
||||
return accountSid
|
||||
? c.account_sid === accountSid
|
||||
: accounts
|
||||
? accounts.map((a) => a.account_sid).includes(c.account_sid || "")
|
||||
: false;
|
||||
})
|
||||
: [];
|
||||
}, [accountSid, clients, accounts]);
|
||||
|
||||
const filteredClients = useFilteredResults(filter, tmpFilteredClients);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (client) {
|
||||
deleteClient(client.client_sid || "")
|
||||
.then(() => {
|
||||
toastSuccess(
|
||||
<>
|
||||
Deleted sip client <strong>{client.username}</strong>
|
||||
</>,
|
||||
);
|
||||
setClient(null);
|
||||
refetch();
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mast">
|
||||
<div>
|
||||
<H1 className="h2">SIP client credentials</H1>
|
||||
{user?.scope === USER_ACCOUNT ? (
|
||||
userData?.account?.sip_realm ? (
|
||||
<>
|
||||
<M>
|
||||
Your sip realm is <span>{userData?.account?.sip_realm}</span>
|
||||
</M>
|
||||
<M>
|
||||
You can add sip credentials below to allow sip devices to
|
||||
register to this realm and make calls.
|
||||
</M>
|
||||
</>
|
||||
) : (
|
||||
<M>
|
||||
You need to associate a sip realm to this account in order to
|
||||
add sip credentials.
|
||||
</M>
|
||||
)
|
||||
) : selectedAccount ? (
|
||||
selectedAccount?.sip_realm ? (
|
||||
<>
|
||||
<M>
|
||||
Your sip realm is <span>{selectedAccount.sip_realm}</span>
|
||||
</M>
|
||||
<M>
|
||||
You can add sip credentials below to allow sip devices to
|
||||
register to this realm and make calls.
|
||||
</M>
|
||||
</>
|
||||
) : (
|
||||
<M>
|
||||
You need to associate a sip realm to this account in order to
|
||||
add sip credentials.
|
||||
</M>
|
||||
)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link to={`${ROUTE_INTERNAL_CLIENTS}/add`} title="Add sip client">
|
||||
{" "}
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="filters filters--multi">
|
||||
<SearchFilter
|
||||
placeholder="Filter clients"
|
||||
filter={[filter, setFilter]}
|
||||
/>
|
||||
<ScopedAccess user={user} scope={Scope.admin}>
|
||||
<AccountFilter
|
||||
account={[accountSid, setAccountSid]}
|
||||
accounts={accounts}
|
||||
label=""
|
||||
defaultOption
|
||||
/>
|
||||
</ScopedAccess>
|
||||
</section>
|
||||
<Section {...(hasLength(filteredClients) && { slim: true })}>
|
||||
<div className="list">
|
||||
{!hasValue(filteredClients) && hasLength(accounts) ? (
|
||||
<Spinner />
|
||||
) : hasLength(filteredClients) ? (
|
||||
filteredClients.map((c) => (
|
||||
<div className="item" key={c.client_sid}>
|
||||
<div className="item__info">
|
||||
<div className="item__title">
|
||||
<Link
|
||||
to={`${ROUTE_INTERNAL_CLIENTS}/${c.client_sid}/edit`}
|
||||
title="Edit outbound call routes"
|
||||
className="i"
|
||||
>
|
||||
<strong>{c.username}</strong>
|
||||
<Icons.ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="item__meta">
|
||||
<div>
|
||||
<div
|
||||
className={`i txt--${c.is_active ? "teal" : "grey"}`}
|
||||
>
|
||||
{c.is_active ? (
|
||||
<Icons.CheckCircle />
|
||||
) : (
|
||||
<Icons.XCircle />
|
||||
)}
|
||||
<span>{c.is_active ? "Active" : "Inactive"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={`i txt--${c.account_sid ? "teal" : "grey"}`}
|
||||
>
|
||||
<Icons.Activity />
|
||||
<span>
|
||||
{
|
||||
accounts?.find(
|
||||
(acct) => acct.account_sid === c.account_sid,
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item__actions">
|
||||
<Link
|
||||
to={`${ROUTE_INTERNAL_CLIENTS}/${c.client_sid}/edit`}
|
||||
title="Edit Client"
|
||||
>
|
||||
<Icons.Edit3 />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title="Delete client"
|
||||
onClick={() => setClient(c)}
|
||||
className="btnty"
|
||||
>
|
||||
<Icons.Trash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<M>No sip clients.</M>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
<Section clean>
|
||||
<Button small as={Link} to={`${ROUTE_INTERNAL_CLIENTS}/add`}>
|
||||
Add sip client
|
||||
</Button>
|
||||
</Section>
|
||||
{client && (
|
||||
<ClientsDelete
|
||||
client={client}
|
||||
handleCancel={() => setClient(null)}
|
||||
handleSubmit={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Clients;
|
||||
21
src/containers/internal/views/least-cost-routing/add.tsx
Normal file
21
src/containers/internal/views/least-cost-routing/add.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import { H1, M } from "@jambonz/ui-kit";
|
||||
|
||||
import { LcrForm } from "./form";
|
||||
|
||||
export const AddLcr = () => {
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Add outbound call routes</H1>
|
||||
<section>
|
||||
<M>
|
||||
Outbound call routing is used to select a carrier when there are
|
||||
multiple carriers available.
|
||||
</M>
|
||||
</section>
|
||||
<LcrForm />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddLcr;
|
||||
176
src/containers/internal/views/least-cost-routing/card.tsx
Normal file
176
src/containers/internal/views/least-cost-routing/card.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from "react";
|
||||
import { Icon } from "@jambonz/ui-kit";
|
||||
import { Identifier, XYCoord } from "dnd-core";
|
||||
import { useRef } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { LcrRoute } from "src/api/types";
|
||||
import { Icons } from "src/components";
|
||||
import { Selector } from "src/components/forms";
|
||||
import { SelectorOption } from "src/components/forms/selector";
|
||||
import "./styles.scss";
|
||||
|
||||
interface DragItem {
|
||||
index: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const ItemTypes = {
|
||||
CARD: "card",
|
||||
};
|
||||
|
||||
type CardProps = {
|
||||
lr: LcrRoute;
|
||||
index: number;
|
||||
moveCard: (dragIndex: number, hoverIndex: number) => void;
|
||||
updateLcrRoute: (index: number, key: string, value: unknown) => void;
|
||||
updateLcrCarrierSetEntries: (
|
||||
index1: number,
|
||||
index2: number,
|
||||
key: string,
|
||||
value: unknown,
|
||||
) => void;
|
||||
handleRouteDelete: (lr: LcrRoute, index: number) => void;
|
||||
carrierSelectorOptions: SelectorOption[];
|
||||
};
|
||||
|
||||
export const Card = ({
|
||||
lr,
|
||||
index,
|
||||
moveCard,
|
||||
updateLcrRoute,
|
||||
updateLcrCarrierSetEntries,
|
||||
handleRouteDelete,
|
||||
carrierSelectorOptions,
|
||||
}: CardProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [{ handlerId }, drop] = useDrop<
|
||||
DragItem,
|
||||
void,
|
||||
{ handlerId: Identifier | null }
|
||||
>({
|
||||
accept: ItemTypes.CARD,
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
};
|
||||
},
|
||||
hover(item: DragItem, monitor) {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
|
||||
// Don't replace items with themselves
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine rectangle on screen
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
|
||||
// Get vertical middle
|
||||
const hoverMiddleY =
|
||||
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
|
||||
// Determine mouse position
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
|
||||
// Get pixels to the top
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
|
||||
// Only perform the move when the mouse has crossed half of the items height
|
||||
// When dragging downwards, only move when the cursor is below 50%
|
||||
// When dragging upwards, only move when the cursor is above 50%
|
||||
|
||||
// Dragging downwards
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dragging upwards
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Time to actually perform the action
|
||||
moveCard(dragIndex, hoverIndex);
|
||||
|
||||
// Note: we're mutating the monitor item here!
|
||||
// Generally it's better to avoid mutations,
|
||||
// but it's good here for the sake of performance
|
||||
// to avoid expensive index searches.
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.CARD,
|
||||
item: () => {
|
||||
return { index };
|
||||
},
|
||||
collect: (monitor) => {
|
||||
return { isDragging: monitor.isDragging() };
|
||||
},
|
||||
});
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`lcr lcr--route lcr-card lcr-card-${
|
||||
isDragging ? "disappear" : "appear"
|
||||
}`}
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
handler-id={handlerId}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
id={`lcr_route_regex_${index}`}
|
||||
name={`lcr_route_regex_${index}`}
|
||||
type="text"
|
||||
placeholder="Digit prefix or regex"
|
||||
required
|
||||
value={lr.regex || ""}
|
||||
onChange={(e) => {
|
||||
updateLcrRoute(index, "regex", e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Selector
|
||||
id={`lcr_carrier_set_entry_carrier_${index}`}
|
||||
name={`lcr_carrier_set_entry_carrier_${index}`}
|
||||
value={
|
||||
lr.lcr_carrier_set_entries && lr.lcr_carrier_set_entries.length > 0
|
||||
? lr.lcr_carrier_set_entries[0].voip_carrier_sid
|
||||
? lr.lcr_carrier_set_entries[0].voip_carrier_sid
|
||||
: ""
|
||||
: ""
|
||||
}
|
||||
required
|
||||
options={carrierSelectorOptions}
|
||||
onChange={(e) => {
|
||||
updateLcrCarrierSetEntries(
|
||||
index,
|
||||
0,
|
||||
"voip_carrier_sid",
|
||||
e.target.value,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btnty btn__delete"
|
||||
title="Delete route"
|
||||
type="button"
|
||||
onClick={() => handleRouteDelete(lr, index)}
|
||||
>
|
||||
<Icon>
|
||||
<Icons.Trash2 />
|
||||
</Icon>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Card;
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from "react";
|
||||
import { LcrRoute } from "src/api/types";
|
||||
import Card from "./card";
|
||||
import { hasLength } from "src/utils";
|
||||
import update from "immutability-helper";
|
||||
import { deleteLcrRoute } from "src/api";
|
||||
import { toastError, toastSuccess } from "src/store";
|
||||
import { SelectorOption } from "src/components/forms/selector";
|
||||
|
||||
type ContainerProps = {
|
||||
lcrRoute: [LcrRoute[], React.Dispatch<React.SetStateAction<LcrRoute[]>>];
|
||||
carrierSelectorOptions: SelectorOption[];
|
||||
};
|
||||
|
||||
export const Container = ({
|
||||
lcrRoute: [lcrRoutes, setLcrRoutes],
|
||||
carrierSelectorOptions,
|
||||
}: ContainerProps) => {
|
||||
const moveCard = (dragIndex: number, hoverIndex: number) => {
|
||||
setLcrRoutes((prevCards) =>
|
||||
update(prevCards, {
|
||||
$splice: [
|
||||
[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)),
|
||||
);
|
||||
};
|
||||
|
||||
const updateLcrCarrierSetEntries = (
|
||||
index1: number,
|
||||
index2: number,
|
||||
key: string,
|
||||
value: unknown,
|
||||
) => {
|
||||
setLcrRoutes(
|
||||
lcrRoutes.map((lr, i) =>
|
||||
i === index1
|
||||
? {
|
||||
...lr,
|
||||
lcr_carrier_set_entries: lr.lcr_carrier_set_entries?.map(
|
||||
(entry, j) =>
|
||||
j === index2
|
||||
? {
|
||||
...entry,
|
||||
[key]: value,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
}
|
||||
: lr,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleRouteDelete = (r: LcrRoute | undefined, index: number) => {
|
||||
if (r && r.lcr_route_sid) {
|
||||
deleteLcrRoute(r.lcr_route_sid)
|
||||
.then(() => {
|
||||
toastSuccess("Least cost routing rule successfully deleted");
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error);
|
||||
});
|
||||
}
|
||||
setLcrRoutes(lcrRoutes.filter((g2, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasLength(lcrRoutes) &&
|
||||
lcrRoutes.map((lr, i) => (
|
||||
<Card
|
||||
key={lr.lcr_route_sid || i}
|
||||
lr={lr}
|
||||
index={i}
|
||||
moveCard={moveCard}
|
||||
updateLcrRoute={updateLcrRoute}
|
||||
updateLcrCarrierSetEntries={updateLcrCarrierSetEntries}
|
||||
handleRouteDelete={handleRouteDelete}
|
||||
carrierSelectorOptions={carrierSelectorOptions}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Container;
|
||||
25
src/containers/internal/views/least-cost-routing/delete.tsx
Normal file
25
src/containers/internal/views/least-cost-routing/delete.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { P } from "@jambonz/ui-kit";
|
||||
import { Modal } from "src/components";
|
||||
import { Lcr } from "src/api/types";
|
||||
|
||||
type DeleteProps = {
|
||||
lcr: Lcr;
|
||||
handleCancel: () => void;
|
||||
handleSubmit: () => void;
|
||||
};
|
||||
|
||||
export const DeleteLcr = ({ lcr, handleCancel, handleSubmit }: DeleteProps) => {
|
||||
return (
|
||||
<>
|
||||
<Modal handleCancel={handleCancel} handleSubmit={handleSubmit}>
|
||||
<P>
|
||||
Are you sure you want to delete least cost routing{" "}
|
||||
<strong>{lcr.name}</strong>?
|
||||
</P>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteLcr;
|
||||
36
src/containers/internal/views/least-cost-routing/edit.tsx
Normal file
36
src/containers/internal/views/least-cost-routing/edit.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { H1, M } from "@jambonz/ui-kit";
|
||||
import LcrForm from "./form";
|
||||
import { useApiData } from "src/api";
|
||||
import { Lcr, LcrRoute } from "src/api/types";
|
||||
import { useParams } from "react-router-dom";
|
||||
export const EditLcr = () => {
|
||||
const params = useParams();
|
||||
const [lcrData, lcrRefect, lcrError] = useApiData<Lcr>(
|
||||
`Lcrs/${params.lcr_sid}`,
|
||||
);
|
||||
const [lcrRouteData, lcrRouteRefect, lcrRouteError] = useApiData<LcrRoute[]>(
|
||||
`LcrRoutes?lcr_sid=${params.lcr_sid}`,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Edit outbound call routes</H1>
|
||||
<section>
|
||||
<M>
|
||||
Outbound call routing is used to select a carrier when there are
|
||||
multiple carriers available.
|
||||
</M>
|
||||
</section>
|
||||
<LcrForm
|
||||
lcrDataMap={{ data: lcrData, refetch: lcrRefect, error: lcrError }}
|
||||
lcrRouteDataMap={{
|
||||
data: lcrRouteData,
|
||||
refetch: lcrRouteRefect,
|
||||
error: lcrRouteError,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditLcr;
|
||||
474
src/containers/internal/views/least-cost-routing/form.tsx
Normal file
474
src/containers/internal/views/least-cost-routing/form.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Button, ButtonGroup, Icon, MS, MXS } from "@jambonz/ui-kit";
|
||||
import { Icons, Section } from "src/components";
|
||||
import {
|
||||
toastError,
|
||||
toastSuccess,
|
||||
useDispatch,
|
||||
useSelectState,
|
||||
} from "src/store";
|
||||
import { 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,
|
||||
LcrRoute,
|
||||
UseApiDataMap,
|
||||
} from "src/api/types";
|
||||
import { ROUTE_INTERNAL_LEST_COST_ROUTING } from "src/router/routes";
|
||||
import {
|
||||
deleteLcr,
|
||||
putLcr,
|
||||
postLcrCreateRoutes,
|
||||
putLcrUpdateRoutes,
|
||||
useApiData,
|
||||
useServiceProviderData,
|
||||
} from "src/api";
|
||||
import { USER_ACCOUNT, USER_ADMIN } from "src/api/constants";
|
||||
import { postLcr } 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 { hasValue } from "src/utils";
|
||||
|
||||
type LcrFormProps = {
|
||||
lcrDataMap?: UseApiDataMap<Lcr>;
|
||||
lcrRouteDataMap?: UseApiDataMap<LcrRoute[]>;
|
||||
};
|
||||
|
||||
export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
|
||||
const LCR_ROUTE_TEMPLATE: LcrRoute = {
|
||||
lcr_route_sid: "",
|
||||
regex: "",
|
||||
lcr_sid: "",
|
||||
priority: 0,
|
||||
lcr_carrier_set_entries: [
|
||||
{
|
||||
lcr_route_sid: "",
|
||||
voip_carrier_sid: "",
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [lcrName, setLcrName] = useState("");
|
||||
const [defaultLcrCarrier, setDefaultLcrCarrier] = useState("");
|
||||
const [defaultLcrCarrierSetEntrySid, setDefaultLcrCarrierSetEntrySid] =
|
||||
useState<string | null>();
|
||||
const [defaultLcrRoute, setDefaultLcrRoute] = useState<LcrRoute | null>(null);
|
||||
const [defaultCarrier, setDefaultCarrier] = useState("");
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [lcrRoutes, setLcrRoutes] = useState<LcrRoute[]>([LCR_ROUTE_TEMPLATE]);
|
||||
const [previousLcrRoutes, setPreviousLcrRoutes] = useState<LcrRoute[]>([
|
||||
LCR_ROUTE_TEMPLATE,
|
||||
]);
|
||||
const [previouseLcr, setPreviousLcr] = useState<Lcr | null>();
|
||||
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||
const [lcrForDelete, setLcrForDelete] = useState<Lcr | null>();
|
||||
|
||||
const user = useSelectState("user");
|
||||
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||
const [carriers] = useApiData<Carrier[]>(apiUrl);
|
||||
|
||||
useEffect(() => {
|
||||
setLocation();
|
||||
if (currentServiceProvider) {
|
||||
setApiUrl(
|
||||
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`,
|
||||
);
|
||||
}
|
||||
}, [user, currentServiceProvider, accountSid]);
|
||||
|
||||
const carrierSelectorOptions = useMemo(() => {
|
||||
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
|
||||
setAccountSid(user?.account_sid);
|
||||
}
|
||||
|
||||
const carriersFiltered = carriers
|
||||
? carriers.filter((carrier) =>
|
||||
accountSid
|
||||
? carrier.account_sid === accountSid
|
||||
: carrier.account_sid === null,
|
||||
)
|
||||
: [];
|
||||
|
||||
const ret = carriersFiltered
|
||||
? carriersFiltered.map((c: Carrier, i) => {
|
||||
if (i === 0) {
|
||||
setDefaultCarrier(c.voip_carrier_sid);
|
||||
}
|
||||
return {
|
||||
name: c.name,
|
||||
value: c.voip_carrier_sid,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
if (carriers && ret.length === 0) {
|
||||
setErrorMessage(
|
||||
accountSid
|
||||
? "There are no available carriers defined for this account"
|
||||
: "There are no available carriers",
|
||||
);
|
||||
} else {
|
||||
setErrorMessage("");
|
||||
}
|
||||
return ret;
|
||||
}, [accountSid, carriers]);
|
||||
|
||||
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data !== previouseLcr) {
|
||||
setLcrName(lcrDataMap.data.name || "");
|
||||
setIsActive(lcrDataMap.data.is_active);
|
||||
setPreviousLcr(lcrDataMap.data);
|
||||
}
|
||||
|
||||
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 !== default_lcr_route_sid,
|
||||
),
|
||||
);
|
||||
}, [lcrRouteDataMap?.data]);
|
||||
|
||||
const addLcrRoutes = () => {
|
||||
const newLcrRoute = LCR_ROUTE_TEMPLATE;
|
||||
const ls = [
|
||||
...lcrRoutes,
|
||||
{
|
||||
...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);
|
||||
};
|
||||
|
||||
const getLcrPayload = (): Lcr => {
|
||||
return {
|
||||
name: lcrName,
|
||||
is_active: isActive,
|
||||
account_sid: accountSid,
|
||||
service_provider_sid:
|
||||
currentServiceProvider?.service_provider_sid || null,
|
||||
default_carrier_set_entry_sid: defaultLcrCarrierSetEntrySid,
|
||||
};
|
||||
};
|
||||
|
||||
const handleLcrPost = () => {
|
||||
const lcrPayload: Lcr = getLcrPayload();
|
||||
postLcr(lcrPayload)
|
||||
.then(({ json }) => {
|
||||
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(() => {
|
||||
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);
|
||||
});
|
||||
})
|
||||
.catch(({ msg }) => {
|
||||
toastError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLcrPut = () => {
|
||||
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data.lcr_sid) {
|
||||
// update LCR
|
||||
const lcrPayload: Lcr = getLcrPayload();
|
||||
putLcr(lcrDataMap.data.lcr_sid, lcrPayload).then(() => {
|
||||
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(() => {
|
||||
toastSuccess("Least cost routing rule successfully updated");
|
||||
})
|
||||
.catch((error) => toastError(error));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (lcrDataMap) {
|
||||
handleLcrPut();
|
||||
} else {
|
||||
handleLcrPost();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (lcrForDelete) {
|
||||
deleteLcr(lcrForDelete.lcr_sid || "")
|
||||
.then(() => {
|
||||
toastSuccess(
|
||||
<>
|
||||
Deleted least cost routing <strong>{lcrForDelete?.name}</strong>
|
||||
</>,
|
||||
);
|
||||
setLcrForDelete(null);
|
||||
if (user?.access === Scope.admin) {
|
||||
navigate(ROUTE_INTERNAL_LEST_COST_ROUTING);
|
||||
} else {
|
||||
navigate(`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`);
|
||||
}
|
||||
dispatch({ type: "lcr" });
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section slim>
|
||||
<form
|
||||
className={`form form--internal ${
|
||||
!lcrDataMap?.data && lcrDataMap?.refetch ? "form--blur" : ""
|
||||
}`}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<fieldset>
|
||||
<MS>{MSG_REQUIRED_FIELDS}</MS>
|
||||
{errorMessage && <Message message={errorMessage} />}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<div className="multi">
|
||||
<div className="inp">
|
||||
<label htmlFor="lcr_name">Name</label>
|
||||
<input
|
||||
id="lcr_name"
|
||||
name="lcr_name"
|
||||
type="text"
|
||||
placeholder="name"
|
||||
value={lcrName}
|
||||
onChange={(e) => setLcrName(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>
|
||||
<div className="sel sel--preset">
|
||||
<label htmlFor="predefined_select">
|
||||
Select a default outbound carrier<span>*</span>
|
||||
</label>
|
||||
<Selector
|
||||
id="defailt_carrier"
|
||||
name="defailt_carrier"
|
||||
value={defaultLcrCarrier}
|
||||
options={carrierSelectorOptions}
|
||||
required
|
||||
onChange={(e) => {
|
||||
setDefaultLcrCarrier(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
{user?.scope === USER_ADMIN && (
|
||||
<fieldset>
|
||||
<AccountSelect
|
||||
accounts={accounts}
|
||||
account={[accountSid, setAccountSid]}
|
||||
label="Used by"
|
||||
required={false}
|
||||
defaultOption={true}
|
||||
disabled={lcrDataMap !== undefined}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
<fieldset>
|
||||
<label htmlFor="lcr_route">
|
||||
Route based on first match<span>*</span>
|
||||
</label>
|
||||
<MXS>
|
||||
<em>Drag and drop to rearrange the order.</em>
|
||||
</MXS>
|
||||
<label htmlFor="sip_gateways">Digit pattern / Carrier</label>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Container
|
||||
lcrRoute={[lcrRoutes, setLcrRoutes]}
|
||||
carrierSelectorOptions={carrierSelectorOptions}
|
||||
/>
|
||||
</DndProvider>
|
||||
<ButtonGroup left>
|
||||
<button
|
||||
className="btnty"
|
||||
type="button"
|
||||
title="Add route"
|
||||
onClick={() => {
|
||||
addLcrRoutes();
|
||||
}}
|
||||
>
|
||||
<Icon subStyle="teal">
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
</button>
|
||||
</ButtonGroup>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<div className="grid grid--col3">
|
||||
<div className="grid__row">
|
||||
<div>
|
||||
<ButtonGroup left>
|
||||
{user?.access === Scope.admin && (
|
||||
<Button
|
||||
small
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={ROUTE_INTERNAL_LEST_COST_ROUTING}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
small
|
||||
disabled={carrierSelectorOptions.length === 0}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div />
|
||||
<div>
|
||||
{user?.scope !== USER_ADMIN &&
|
||||
lcrDataMap &&
|
||||
lcrDataMap.data &&
|
||||
lcrDataMap.data.lcr_sid && (
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
type="button"
|
||||
small
|
||||
subStyle="grey"
|
||||
onClick={() => {
|
||||
setLcrForDelete(lcrDataMap.data);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Section>
|
||||
{lcrForDelete && (
|
||||
<DeleteLcr
|
||||
lcr={lcrForDelete}
|
||||
handleCancel={() => setLcrForDelete(null)}
|
||||
handleSubmit={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LcrForm;
|
||||
223
src/containers/internal/views/least-cost-routing/index.tsx
Normal file
223
src/containers/internal/views/least-cost-routing/index.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Button, H1, Icon, M } from "@jambonz/ui-kit";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { deleteLcr, useApiData, useServiceProviderData } from "src/api";
|
||||
// import { USER_ACCOUNT } from "src/api/constants";
|
||||
import type { Account, Lcr } from "src/api/types";
|
||||
import {
|
||||
AccountFilter,
|
||||
Icons,
|
||||
SearchFilter,
|
||||
Section,
|
||||
Spinner,
|
||||
} from "src/components";
|
||||
import { ScopedAccess } from "src/components/scoped-access";
|
||||
import { ROUTE_INTERNAL_LEST_COST_ROUTING } from "src/router/routes";
|
||||
import { toastSuccess, toastError, useSelectState } from "src/store";
|
||||
// import { getAccountFilter, setLocation } from "src/store/localStore";
|
||||
import { Scope } from "src/store/types";
|
||||
import {
|
||||
hasLength,
|
||||
hasValue,
|
||||
useFilteredResults,
|
||||
useScopedRedirect,
|
||||
} from "src/utils";
|
||||
import { USER_ACCOUNT } from "src/api/constants";
|
||||
import DeleteLcr from "./delete";
|
||||
|
||||
export const Lcrs = () => {
|
||||
const user = useSelectState("user");
|
||||
useScopedRedirect(
|
||||
Scope.admin,
|
||||
`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`,
|
||||
user,
|
||||
"You do not have permissions to manage all outbound call routes",
|
||||
);
|
||||
const [lcrs, refetch] = useApiData<Lcr[]>("Lcrs");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
|
||||
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||
const [lcr, setLcr] = useState<Lcr | null>();
|
||||
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||
|
||||
const lcrsFiltered = useMemo(() => {
|
||||
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
|
||||
setAccountSid(user?.account_sid);
|
||||
return lcrs;
|
||||
}
|
||||
|
||||
return lcrs
|
||||
? lcrs.filter((lcr) =>
|
||||
accountSid
|
||||
? lcr.account_sid === accountSid
|
||||
: currentServiceProvider?.service_provider_sid
|
||||
? lcr.service_provider_sid ==
|
||||
currentServiceProvider.service_provider_sid
|
||||
: lcr.account_sid === null,
|
||||
)
|
||||
: [];
|
||||
}, [accountSid, lcrs]);
|
||||
const filteredLcrs = useFilteredResults<Lcr>(filter, lcrsFiltered);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (lcr) {
|
||||
deleteLcr(lcr.lcr_sid || "")
|
||||
.then(() => {
|
||||
toastSuccess(
|
||||
<>
|
||||
Deleted outbound call route <strong>{lcr?.name}</strong>
|
||||
</>,
|
||||
);
|
||||
setLcr(null);
|
||||
refetch();
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mast">
|
||||
<H1 className="h2">Outbound call routing</H1>
|
||||
<Link
|
||||
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`}
|
||||
title="Add a Least cost routing"
|
||||
>
|
||||
{" "}
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
</Link>
|
||||
</section>
|
||||
<section>
|
||||
<M>
|
||||
Outbound call routing is used to select a carrier when there are
|
||||
multiple carriers available.
|
||||
</M>
|
||||
</section>
|
||||
<section className="filters filters--multi">
|
||||
<SearchFilter placeholder="Filter lcrs" filter={[filter, setFilter]} />
|
||||
<ScopedAccess user={user} scope={Scope.admin}>
|
||||
<AccountFilter
|
||||
account={[accountSid, setAccountSid]}
|
||||
accounts={accounts}
|
||||
label="Used by"
|
||||
defaultOption
|
||||
/>
|
||||
</ScopedAccess>
|
||||
</section>
|
||||
<Section {...(hasLength(filteredLcrs) && { slim: true })}>
|
||||
<div className="list">
|
||||
{!hasValue(filteredLcrs) && hasLength(accounts) ? (
|
||||
<Spinner />
|
||||
) : hasLength(filteredLcrs) ? (
|
||||
filteredLcrs.map((lcr) => (
|
||||
<div className="item" key={lcr.lcr_sid}>
|
||||
<div className="item__info">
|
||||
<div className="item__title">
|
||||
<ScopedAccess
|
||||
user={user}
|
||||
scope={
|
||||
!lcr.account_sid
|
||||
? Scope.service_provider
|
||||
: Scope.account
|
||||
}
|
||||
>
|
||||
<Link
|
||||
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`}
|
||||
title="Edit outbound call routes"
|
||||
className="i"
|
||||
>
|
||||
<strong>{lcr.name}</strong>
|
||||
<Icons.ArrowRight />
|
||||
</Link>
|
||||
</ScopedAccess>
|
||||
</div>
|
||||
<div className="item__meta">
|
||||
<div>
|
||||
<div
|
||||
className={`i txt--${lcr.is_active ? "teal" : "grey"}`}
|
||||
>
|
||||
{lcr.is_active ? (
|
||||
<Icons.CheckCircle />
|
||||
) : (
|
||||
<Icons.XCircle />
|
||||
)}
|
||||
<span>{lcr.is_active ? "Active" : "Inactive"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`i txt--teal`}>
|
||||
<Icons.Activity />
|
||||
<span>
|
||||
{lcr.account_sid
|
||||
? accounts?.find(
|
||||
(acct) => acct.account_sid === lcr.account_sid,
|
||||
)?.name
|
||||
: currentServiceProvider?.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`i txt--teal`}>
|
||||
<Icons.Share2 />
|
||||
<span>{`${
|
||||
lcr.number_routes && lcr.number_routes > 1
|
||||
? lcr.number_routes - 1
|
||||
: 0
|
||||
} routes`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScopedAccess
|
||||
user={user}
|
||||
scope={
|
||||
!lcr.account_sid ? Scope.service_provider : Scope.account
|
||||
}
|
||||
>
|
||||
<div className="item__actions">
|
||||
<Link
|
||||
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`}
|
||||
title="Edit Client"
|
||||
>
|
||||
<Icons.Edit3 />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title="Delete outbound call route"
|
||||
onClick={() => setLcr(lcr)}
|
||||
className="btnty"
|
||||
>
|
||||
<Icons.Trash />
|
||||
</button>
|
||||
</div>
|
||||
</ScopedAccess>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<M>No outbound call routes.</M>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
<Section clean>
|
||||
<Button small as={Link} to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`}>
|
||||
Add outbound call routes
|
||||
</Button>
|
||||
</Section>
|
||||
{lcr && (
|
||||
<DeleteLcr
|
||||
lcr={lcr}
|
||||
handleCancel={() => setLcr(null)}
|
||||
handleSubmit={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Lcrs;
|
||||
19
src/containers/internal/views/least-cost-routing/styles.scss
Normal file
19
src/containers/internal/views/least-cost-routing/styles.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
.lcr-card {
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lcr-card:hover {
|
||||
box-shadow:
|
||||
-7px 7px 5px #d5d7db,
|
||||
-5px -5px 10px #ffffff;
|
||||
transform: translateY(-3px) translateX(-3px);
|
||||
}
|
||||
|
||||
.lcr-card-appear {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lcr-card-disappear {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
: []
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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))
|
||||
: []
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
src/containers/internal/views/recent-calls/call-detail.tsx
Normal file
29
src/containers/internal/views/recent-calls/call-detail.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { RecentCall } from "src/api/types";
|
||||
|
||||
export type CallDetailProps = {
|
||||
call: RecentCall;
|
||||
};
|
||||
|
||||
export const CallDetail = ({ call }: CallDetailProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="item__details">
|
||||
<div className="pre-grid">
|
||||
{Object.keys(call).map((key) => (
|
||||
<React.Fragment key={key}>
|
||||
<div>{key}:</div>
|
||||
<div>
|
||||
{call[key as keyof typeof call]
|
||||
? String(call[key as keyof typeof call])
|
||||
: "null"}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CallDetail;
|
||||
131
src/containers/internal/views/recent-calls/call-system-logs.tsx
Normal file
131
src/containers/internal/views/recent-calls/call-system-logs.tsx
Normal 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>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
150
src/containers/internal/views/recent-calls/call-tracing.tsx
Normal file
150
src/containers/internal/views/recent-calls/call-tracing.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Bar } from "./jaeger/bar";
|
||||
import { JaegerGroup, JaegerRoot, JaegerSpan } from "src/api/jaeger-types";
|
||||
import { getJaegerTrace } from "src/api";
|
||||
import { RecentCall } from "src/api/types";
|
||||
import { getSpansFromJaegerRoot } from "./utils";
|
||||
|
||||
function useWindowSize() {
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
handleResize();
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
return windowSize;
|
||||
}
|
||||
|
||||
export type CallTracingProps = {
|
||||
call: RecentCall;
|
||||
};
|
||||
|
||||
export const CallTracing = ({ call }: CallTracingProps) => {
|
||||
const [jaegerRoot, setJaegerRoot] = useState<JaegerRoot>();
|
||||
const [jaegerGroup, setJaegerGroup] = useState<JaegerGroup>();
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
const getGroupsByParent = (spanId: string, groups: JaegerGroup[]) => {
|
||||
groups.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
|
||||
return groups.filter((value) => value.parentSpanId === spanId);
|
||||
};
|
||||
|
||||
const getRootSpan = (spans: JaegerSpan[]) => {
|
||||
const spanIds = spans.map((value) => value.spanId);
|
||||
return spans.find((value) => spanIds.indexOf(value.parentSpanId) == -1);
|
||||
};
|
||||
|
||||
const getRootGroup = (grps: JaegerGroup[]) => {
|
||||
const spanIds = grps.map((value) => value.spanId);
|
||||
return grps.find((value) => spanIds.indexOf(value.parentSpanId) == -1);
|
||||
};
|
||||
|
||||
const calculateRatio = (span: JaegerSpan) => {
|
||||
const { innerWidth } = window;
|
||||
const durationMs =
|
||||
(span.endTimeUnixNano - span.startTimeUnixNano) / 1_000_000;
|
||||
|
||||
if (durationMs > innerWidth) {
|
||||
const offset = innerWidth > 1200 ? 3 : innerWidth > 800 ? 2.5 : 2;
|
||||
return durationMs / (innerWidth - innerWidth / offset);
|
||||
}
|
||||
|
||||
return 1;
|
||||
};
|
||||
|
||||
const buildSpans = (root: JaegerRoot) => {
|
||||
setJaegerRoot(root);
|
||||
const spans = getSpansFromJaegerRoot(root);
|
||||
const rootSpan = getRootSpan(spans);
|
||||
if (rootSpan) {
|
||||
const startTime = rootSpan.startTimeUnixNano;
|
||||
const ratio = calculateRatio(rootSpan);
|
||||
calculateRatio(rootSpan);
|
||||
const groups: JaegerGroup[] = spans.map((span) => {
|
||||
const level = 0;
|
||||
const children: JaegerGroup[] = [];
|
||||
const startMs = (span.startTimeUnixNano - startTime) / 1_000_000;
|
||||
const durationMs =
|
||||
(span.endTimeUnixNano - span.startTimeUnixNano) / 1_000_000;
|
||||
const startPx = startMs / ratio;
|
||||
const durationPx = durationMs / ratio;
|
||||
const endPx = startPx + durationPx;
|
||||
const endMs = startMs + durationMs;
|
||||
return {
|
||||
level,
|
||||
children,
|
||||
startPx,
|
||||
endPx,
|
||||
durationPx,
|
||||
startMs,
|
||||
endMs,
|
||||
durationMs,
|
||||
...span,
|
||||
};
|
||||
});
|
||||
|
||||
const rootGroup = getRootGroup(groups);
|
||||
if (rootGroup) {
|
||||
rootGroup.children = buildChildren(
|
||||
rootGroup.level + 1,
|
||||
rootGroup,
|
||||
groups,
|
||||
);
|
||||
setJaegerGroup(rootGroup);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildChildren = (
|
||||
level: number,
|
||||
rootGroup: JaegerGroup,
|
||||
groups: JaegerGroup[],
|
||||
): JaegerGroup[] => {
|
||||
return getGroupsByParent(rootGroup.spanId, groups).map((group) => {
|
||||
group.level = level;
|
||||
group.children = buildChildren(group.level + 1, group, groups);
|
||||
return group;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
|
||||
getJaegerTrace(call.account_sid, call.trace_id).then(({ json }) => {
|
||||
if (json) {
|
||||
buildSpans(json);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (jaegerRoot) {
|
||||
buildSpans(jaegerRoot);
|
||||
}
|
||||
}, [windowSize]);
|
||||
|
||||
if (jaegerGroup) {
|
||||
return (
|
||||
<>
|
||||
<div className="item__details">
|
||||
<div className="barGroup">
|
||||
<Bar group={jaegerGroup} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CallTracing;
|
||||
@@ -2,10 +2,16 @@ import React, { useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { Icons } from "src/components";
|
||||
import { formatPhoneNumber } from "src/utils";
|
||||
import { formatPhoneNumber, hasValue } from "src/utils";
|
||||
import { PcapButton } from "./pcap";
|
||||
|
||||
import type { RecentCall } from "src/api/types";
|
||||
import { Tabs, Tab } from "@jambonz/ui-kit";
|
||||
import CallDetail from "./call-detail";
|
||||
import CallTracing from "./call-tracing";
|
||||
import { 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;
|
||||
@@ -13,6 +19,13 @@ type DetailsItemProps = {
|
||||
|
||||
export const DetailsItem = ({ call }: DetailsItemProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("");
|
||||
|
||||
const transformRecentCall = (call: RecentCall): RecentCall => {
|
||||
const newCall = { ...call };
|
||||
delete newCall.recording_url;
|
||||
return newCall;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="item">
|
||||
@@ -28,7 +41,7 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
|
||||
<div className="item__info">
|
||||
<div className="item__title">
|
||||
<strong>
|
||||
{dayjs(call.attempted_at).format("YYYY MM.DD hh:mm a")}
|
||||
{dayjs(call.attempted_at).format("YYYY MM.DD hh:mm:ss a")}
|
||||
</strong>
|
||||
<span className="i txt--dark">
|
||||
{call.direction === "inbound" ? (
|
||||
@@ -55,21 +68,36 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="item__details">
|
||||
<div className="pre-grid">
|
||||
{Object.keys(call).map((key) => (
|
||||
<React.Fragment key={key}>
|
||||
<div>{key}:</div>
|
||||
<div>
|
||||
{call[key as keyof typeof call]
|
||||
? call[key as keyof typeof call].toString()
|
||||
: "null"}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{open && <PcapButton call={call} />}
|
||||
</div>
|
||||
{call.trace_id === "00000000000000000000000000000000" ||
|
||||
DISABLE_JAEGER_TRACING ? (
|
||||
<CallDetail call={transformRecentCall(call)} />
|
||||
) : (
|
||||
<Tabs active={[activeTab, setActiveTab]}>
|
||||
<Tab id="details" label="Details">
|
||||
<CallDetail call={transformRecentCall(call)} />
|
||||
</Tab>
|
||||
<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 && (
|
||||
<>
|
||||
<div className="footer">
|
||||
{hasValue(call.recording_url) && <Player call={call} />}
|
||||
<div className="footer__buttons">
|
||||
<PcapButton call={call} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Spinner,
|
||||
Pagination,
|
||||
SelectFilter,
|
||||
SearchFilter,
|
||||
} from "src/components";
|
||||
import { hasLength, hasValue } from "src/utils";
|
||||
import { DetailsItem } from "./details";
|
||||
@@ -47,6 +48,7 @@ export const RecentCalls = () => {
|
||||
const [dateFilter, setDateFilter] = useState("today");
|
||||
const [directionFilter, setDirectionFilter] = useState("io");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [perPageFilter, setPerPageFilter] = useState("25");
|
||||
@@ -61,9 +63,15 @@ 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 }),
|
||||
};
|
||||
|
||||
getRecentCalls(accountSid, payload)
|
||||
@@ -94,7 +102,14 @@ export const RecentCalls = () => {
|
||||
if (accountSid) {
|
||||
handleFilterChange();
|
||||
}
|
||||
}, [accountSid, pageNumber, dateFilter, directionFilter, statusFilter]);
|
||||
}, [
|
||||
accountSid,
|
||||
pageNumber,
|
||||
dateFilter,
|
||||
directionFilter,
|
||||
statusFilter,
|
||||
filter,
|
||||
]);
|
||||
|
||||
/** Reset page number when filters change */
|
||||
useEffect(() => {
|
||||
@@ -136,13 +151,21 @@ export const RecentCalls = () => {
|
||||
filter={[statusFilter, setStatusFilter]}
|
||||
options={statusSelection}
|
||||
/>
|
||||
<SearchFilter
|
||||
placeholder="Filter"
|
||||
filter={[filter, setFilter]}
|
||||
delay={1000}
|
||||
/>
|
||||
</section>
|
||||
<Section {...(hasLength(calls) && { slim: true })}>
|
||||
<div className="list">
|
||||
{!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>
|
||||
)}
|
||||
|
||||
66
src/containers/internal/views/recent-calls/jaeger/bar.tsx
Normal file
66
src/containers/internal/views/recent-calls/jaeger/bar.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from "react";
|
||||
import { JaegerGroup } from "src/api/jaeger-types";
|
||||
|
||||
import "./styles.scss";
|
||||
import { formattedDuration } from "./utils";
|
||||
import { JaegerDetail } from "./detail";
|
||||
import { ModalClose } from "src/components";
|
||||
import { P } from "@jambonz/ui-kit";
|
||||
|
||||
type BarProps = {
|
||||
group: JaegerGroup;
|
||||
};
|
||||
|
||||
export const Bar = ({ group }: BarProps) => {
|
||||
const [jaegerDetail, setJaegerDetail] = useState<JaegerGroup | null>(null);
|
||||
const titleMargin = group.level * 30;
|
||||
|
||||
const truncate = (str: string) => {
|
||||
if (str.length > 36) {
|
||||
return str.substring(0, 36) + "...";
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="barWrapper"
|
||||
role={"presentation"}
|
||||
onClick={() => setJaegerDetail(group)}
|
||||
>
|
||||
<div role="presentation" className="barWrapper__row">
|
||||
<div
|
||||
className="barWrapper__header"
|
||||
style={{ paddingLeft: `${titleMargin}px` }}
|
||||
>
|
||||
{truncate(group.name)}
|
||||
</div>
|
||||
<button
|
||||
className="barWrapper__span"
|
||||
style={{
|
||||
marginLeft: `${group.startPx}px`,
|
||||
width: group.durationPx,
|
||||
}}
|
||||
/>
|
||||
<div className="barWrapper__duration">
|
||||
{formattedDuration(group.durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{jaegerDetail && (
|
||||
<ModalClose handleClose={() => setJaegerDetail(null)}>
|
||||
<div className="spanDetailsWrapper__header">
|
||||
<P>
|
||||
<strong>Span:</strong> {group.name.replaceAll(",", ", ")}
|
||||
</P>
|
||||
</div>
|
||||
<JaegerDetail group={jaegerDetail} />
|
||||
</ModalClose>
|
||||
)}
|
||||
{group.children.map((value) => (
|
||||
<Bar key={value.spanId} group={value} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
103
src/containers/internal/views/recent-calls/jaeger/detail.tsx
Normal file
103
src/containers/internal/views/recent-calls/jaeger/detail.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from "react";
|
||||
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;
|
||||
};
|
||||
|
||||
const extractSpanGroupValue = (value: JaegerValue): string => {
|
||||
const ret = String(value.stringValue || value.doubleValue || value.boolValue);
|
||||
// add white space for wrap the line
|
||||
return ret.replaceAll(",", ", ");
|
||||
};
|
||||
|
||||
export const JaegerDetail = ({ group }: JaegerDetailProps) => {
|
||||
return (
|
||||
<div className="spanDetailsWrapper">
|
||||
<div className="spanDetailsWrapper__detailsWrapper">
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Span ID:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">{group.spanId}</div>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Span Start:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{dayjs
|
||||
.unix(group.startTimeUnixNano / 1000000000)
|
||||
.format("DD/MM/YY HH:mm:ss.SSS")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Span End:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{dayjs
|
||||
.unix(group.endTimeUnixNano / 1000000000)
|
||||
.format("DD/MM/YY HH:mm:ss.SSS")}
|
||||
</div>
|
||||
</div>
|
||||
{!(group.name && group.name.startsWith("dtmf:")) && (
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Duration:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{formattedDuration(group.durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.attributes.map((attribute) => (
|
||||
<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>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
124
src/containers/internal/views/recent-calls/jaeger/styles.scss
Normal file
124
src/containers/internal/views/recent-calls/jaeger/styles.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
@use "src/styles/vars";
|
||||
@use "src/styles/mixins";
|
||||
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
|
||||
|
||||
.barGroup {
|
||||
border-radius: ui-vars.$px01;
|
||||
@include mixins.code();
|
||||
text-align: left;
|
||||
padding: ui-vars.$px03;
|
||||
color: ui-vars.$pink;
|
||||
background-color: ui-vars.$dark;
|
||||
border-radius: ui-vars.$px01;
|
||||
margin-top: ui-vars.$px02;
|
||||
overflow-x: auto;
|
||||
overflow-y: scroll;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 15px;
|
||||
height: 40vh;
|
||||
}
|
||||
}
|
||||
|
||||
.barWrapper {
|
||||
width: 100%;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__row:hover {
|
||||
font-weight: bolder;
|
||||
color: ui-vars.$purple;
|
||||
}
|
||||
|
||||
&__row:hover button {
|
||||
font-weight: bolder;
|
||||
background-color: ui-vars.$purple;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__header {
|
||||
min-width: 400px;
|
||||
height: 15px;
|
||||
padding-top: 4px;
|
||||
font-size: small;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding-top: 4px;
|
||||
min-width: 250px;
|
||||
font-size: x-small;
|
||||
}
|
||||
}
|
||||
|
||||
&__span {
|
||||
padding-top: 4px;
|
||||
height: 5px;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
border: 3px solid #444;
|
||||
border-radius: 8px;
|
||||
background-color: ui-vars.$jambonz;
|
||||
min-width: 6px;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
margin: 5px;
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.spanDetailsWrapper {
|
||||
border-radius: ui-vars.$px01;
|
||||
@include mixins.code();
|
||||
text-align: left;
|
||||
padding: ui-vars.$px01;
|
||||
background-color: ui-vars.$white;
|
||||
border-radius: ui-vars.$px01;
|
||||
color: ui-vars.$dark;
|
||||
font-size: ui-vars.$mxs-size;
|
||||
max-width: ui-vars.$width-tablet-2;
|
||||
max-height: 500px;
|
||||
overflow-y: scroll;
|
||||
|
||||
&__detailsWrapper {
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
font-size: 0.9em;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 5px;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
padding: 15px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
&__details {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__details_header {
|
||||
padding: 3px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
&__details_body {
|
||||
flex-grow: 1;
|
||||
white-space: pre-line;
|
||||
padding: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
background-color: ui-vars.$white;
|
||||
color: ui-vars.$jambonz;
|
||||
padding: 5px 10px 10px 10px;
|
||||
border-bottom: thin solid ui-vars.$grey;
|
||||
}
|
||||
}
|
||||
16
src/containers/internal/views/recent-calls/jaeger/utils.tsx
Normal file
16
src/containers/internal/views/recent-calls/jaeger/utils.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export const formattedDuration = (duration: number) => {
|
||||
if (duration < 1) {
|
||||
return (Math.round(duration * 100) / 100).toFixed(2) + "ms";
|
||||
} else if (duration < 1000) {
|
||||
return (Math.round(duration * 100) / 100).toFixed(0) + "ms";
|
||||
} else if (duration >= 1000) {
|
||||
const min = Math.floor((duration / 1000 / 60) << 0);
|
||||
if (min == 0) {
|
||||
const secs = parseFloat(`${duration / 1000}`).toFixed(2);
|
||||
return `${secs}s`;
|
||||
} else {
|
||||
const sec = Math.floor((duration / 1000) % 60);
|
||||
return `${min}m ${sec}s`;
|
||||
}
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user