Compare commits

..

2 Commits

Author SHA1 Message Date
Quan HL
001ff8c7a7 improve alert view 2023-06-17 14:28:50 +07:00
Quan HL
12c2ea1f82 improve alert view 2023-06-17 14:27:13 +07:00
165 changed files with 16754 additions and 16471 deletions

28
.env
View File

@@ -1,5 +1,5 @@
# VITE_API_BASE_URL=http://127.0.0.1:3000/v1
#VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
VITE_API_BASE_URL=http://127.0.0.1:3000/v1
VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
## enables choosing units and lisenced account call limits
# VITE_APP_ENABLE_ACCOUNT_LIMITS_ALL=true
@@ -11,26 +11,4 @@
## 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
## enable lazy loading for phone numbers (improves performance when managing large quantities)
# VITE_APP_ENABLE_PHONE_NUMBER_LAZY_LOAD=true
# hides controlls to add Carrier and Phone number from non Admin/SP Users (also need to set flag on API server to block API calls)
#VITE_ADMIN_CARRIER=1
#VITE_APP_DISABLE_CALL_RECORDING=true

View File

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

View File

@@ -1,4 +1,4 @@
FROM node:20-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:20-alpine AS webapp
FROM node:18.14.1-alpine as webapp
RUN apk add curl
WORKDIR /opt/app
COPY . /opt/app

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ 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}\",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}\",ADMIN_CARRIER: \"${ADMIN_CARRIER}\"};</script>"
SCRIPT_TAG="<script>window.JAMBONZ = {API_BASE_URL: \"${API_BASE_URL}\",DISABLE_LCR: \"${DISABLE_LCR}\",DISABLE_JAEGER_TRACING: \"${DISABLE_JAEGER_TRACING}\",DISABLE_CUSTOM_SPEECH: \"${DISABLE_CUSTOM_SPEECH}\",ENABLE_FORGOT_PASSWORD: \"${ENABLE_FORGOT_PASSWORD}\",DISABLE_CALL_RECORDING: \"${DISABLE_CALL_RECORDING}\"};</script>"
sed -i -e "\@</head>@i\ $SCRIPT_TAG" ./dist/index.html
# Start the frontend web app static server

View File

@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Build innovative voice and collaboration services with jambonz, the open-source communication platform for conversational AI providers and CSPs."
content="Simple provisioning webapp for jambonz."
/>
<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 Portal | Jambonz CPaaS</title>
<title>Jambonz Web App</title>
</head>
<body>
<div id="root"></div>

11990
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
{
"name": "jambonz-webapp",
"description": "A simple provisioning web app for jambonz",
"version": "0.9.5",
"version": "0.8.3",
"license": "MIT",
"type": "module",
"engines": {
"node": ">=18"
"node": ">=14.18"
},
"contributors": [
{
@@ -41,46 +41,45 @@
"deploy": "npm i && npm run build && npm run pm2"
},
"dependencies": {
"@jambonz/ui-kit": "^0.0.22",
"@stripe/react-stripe-js": "^2.6.2",
"@stripe/stripe-js": "^3.2.0",
"dayjs": "^1.11.10",
"@jambonz/ui-kit": "^0.0.21",
"dayjs": "^1.11.5",
"immutability-helper": "^3.1.1",
"react": "^18.2.0",
"react": "^18.0.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "^18.2.0",
"react-dom": "^18.0.0",
"react-feather": "^2.0.10",
"react-router-dom": "^6.22.3",
"wavesurfer.js": "^7.7.9"
"react-router-dom": "^6.3.0",
"wavesurfer.js": "^6.6.3"
},
"devDependencies": {
"@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",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/node": "^18.6.1",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"@vitejs/plugin-react": "^1.3.0",
"cors": "^2.8.5",
"cypress": "^13.7.2",
"cypress": "^10.8.0",
"eslint": "^8.19.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.34.1",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jsx-a11y": "^6.6.0",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"express": "^5.1.0",
"husky": "^9.0.11",
"lint-staged": "^16.1.2",
"nanoid": "^5.1.5",
"prettier": "^3.2.5",
"sass": "^1.89.2",
"serve": "^14.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.4.4",
"vite": "^6.4.1"
"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"
},
"lint-staged": {
"*.{ts,tsx}": "eslint --max-warnings=0",

View File

@@ -111,14 +111,14 @@ app.get(
page: query.page,
data: paged,
});
},
}
);
app.get(
"/api/Accounts/:account_sid/RecentCalls/:call_sid",
(req: Request, res: Response) => {
res.status(200).json({ total: Math.random() > 0.5 ? 1 : 0 });
},
}
);
app.get(
@@ -126,7 +126,7 @@ app.get(
(req: Request, res: Response) => {
/** Sample pcap file from: https://wiki.wireshark.org/SampleCaptures#sip-and-rtp */
const pcap: Buffer = fs.readFileSync(
path.resolve(process.cwd(), "server", "sample-sip-rtp-traffic.pcap"),
path.resolve(process.cwd(), "server", "sample-sip-rtp-traffic.pcap")
);
res
@@ -136,7 +136,7 @@ app.get(
"Content-Disposition": "attachment",
})
.send(pcap); // server: Buffer => client: Blob
},
}
);
app.get(
@@ -144,7 +144,7 @@ app.get(
(req: Request, res: Response) => {
/** Sample pcap file from: https://wiki.wireshark.org/SampleCaptures#sip-and-rtp */
const wav: Buffer = fs.readFileSync(
path.resolve(process.cwd(), "server", "example.mp3"),
path.resolve(process.cwd(), "server", "example.mp3")
);
res
@@ -154,7 +154,7 @@ app.get(
"Content-Disposition": "attachment",
})
.send(wav); // server: Buffer => client: Blob
},
}
);
app.get(
@@ -162,10 +162,10 @@ app.get(
(req: Request, res: Response) => {
const json = fs.readFileSync(
path.resolve(process.cwd(), "server", "sample-jaeger.json"),
{ encoding: "utf8" },
{ encoding: "utf8" }
);
res.status(200).json(JSON.parse(json));
},
}
);
/** Alerts mock API responses for local dev */

View File

@@ -1,21 +1,13 @@
import type {
CartesiaOptions,
Currency,
ElevenLabsOptions,
GoogleCustomVoice,
InworldOptions,
LimitField,
LimitUnitOption,
PasswordSettings,
PlayHTOptions,
RimelabsOptions,
SelectorOptions,
SipGateway,
SmppGateway,
WebHook,
WebhookOption,
} from "./types";
import { Vendor } from "src/vendor/types";
/** This window object is serialized and injected at docker runtime */
/** The API url is constructed with the docker containers `ip:port` */
@@ -25,17 +17,7 @@ interface JambonzWindowObject {
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;
ENABLE_PHONE_NUMBER_LAZY_LOAD: string;
ADMIN_CARRIER: string;
}
declare global {
@@ -45,12 +27,8 @@ declare global {
}
/** https://vitejs.dev/guide/env-and-mode.html#env-files */
const CONFIGURED_API_BASE_URL =
window.JAMBONZ?.API_BASE_URL || import.meta.env.VITE_API_BASE_URL;
export const API_BASE_URL =
CONFIGURED_API_BASE_URL && CONFIGURED_API_BASE_URL.length !== 0
? CONFIGURED_API_BASE_URL
: `${window.location.protocol}//${window.location.hostname}/api/v1`;
window.JAMBONZ?.API_BASE_URL || import.meta.env.VITE_API_BASE_URL;
/** Serves mock API responses from a local dev API server */
export const DEV_BASE_URL = import.meta.env.VITE_DEV_BASE_URL;
@@ -63,12 +41,7 @@ export const DISABLE_CUSTOM_SPEECH: boolean =
/** 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");
JSON.parse(import.meta.env.VITE_ENABLE_FORGOT_PASSWORD || "false");
/** Disable Lcr */
export const DISABLE_LCR: boolean =
window.JAMBONZ?.DISABLE_LCR === "true" ||
@@ -84,35 +57,6 @@ 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;
export const ADMIN_CARRIER: string =
window.JAMBONZ?.ADMIN_CARRIER || import.meta.env.VITE_ADMIN_CARRIER || "0";
/** TCP Max Port */
export const TCP_MAX_PORT = 65535;
@@ -134,7 +78,7 @@ export const DEFAULT_WEBHOOK: WebHook = {
};
/** Default SIP/SMPP Gateways */
export const DEFAULT_SIP_INBOUND_GATEWAY: SipGateway = {
export const DEFAULT_SIP_GATEWAY: SipGateway = {
voip_carrier_sid: "",
ipv4: "",
port: 5060,
@@ -187,10 +131,6 @@ export const SIP_GATEWAY_PROTOCOL_OPTIONS = [
/**
* 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",
@@ -198,19 +138,7 @@ export const BUCKET_VENDOR_OPTIONS = [
},
{
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,
value: "aws_s3",
},
];
@@ -224,96 +152,6 @@ export const AUDIO_FORMAT_OPTIONS = [
value: "wav",
},
];
export const LOG_LEVEL_OPTIONS = [
{
name: "Info",
value: "info",
},
{
name: "Debug",
value: "debug",
},
];
export const DEFAULT_ELEVENLABS_MODEL = "eleven_multilingual_v2";
export const DEFAULT_WHISPER_MODEL = "tts-1";
// VERBIO
export const VERBIO_STT_MODELS = [
{ name: "V1", value: "V1" },
{ name: "V2", value: "V2" },
];
export const DEFAULT_VERBIO_MODEL = "V1";
// ASSEMBLYAI
export const ASSEMBLYAI_STT_VERSIONS = [
{ name: "V2", value: "v2" },
{ name: "V3", value: "v3" },
];
export const DEFAULT_ASSEMBLYAI_STT_VERSION = "v2";
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,
};
export const DEFAULT_INWORLD_OPTIONS: Partial<InworldOptions> = {
audioConfig: {
pitch: 0.0,
speakingRate: 1.0,
},
temperature: 0.8,
};
// 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;
@@ -327,7 +165,6 @@ 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" },
@@ -346,17 +183,6 @@ 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" },
];
export const TRUNK_TYPE_SELECTION: SelectorOptions[] = [
{ name: "IP Trunk", value: "static_ip" },
{ name: "Auth Trunk", value: "auth" },
{ name: "Registration Trunk", value: "reg" },
];
/** Available webhook methods */
export const WEBHOOK_METHODS: WebhookOption[] = [
{
@@ -417,28 +243,6 @@ export const DEFAULT_PSWD_SETTINGS: PasswordSettings = {
require_special_character: 0,
};
export const PlanType = {
PAID: "paid",
TRIAL: "trial",
FREE: "free",
};
export const CurrencySymbol: Currency = {
usd: "$",
};
export const DEEPGRAM_STT_ENPOINT = [
{ name: "US (Default)", value: "" },
{ name: "EU-hosted", value: "api.eu.deepgram.com" },
];
// ElevenLabs API URI options
export const ELEVENLABS_API_URI_OPTIONS = [
{ name: "US", value: "api.elevenlabs.io" },
{ name: "EU", value: "api.eu.residency.elevenlabs.io" },
{ name: "IN", value: "api.in.residency.elevenlabs.io" },
];
/** User scope values values */
export const USER_ADMIN = "admin";
export const USER_SP = "service_provider";
@@ -453,9 +257,6 @@ export const CRED_NOT_TESTED = "not tested";
export const CARRIER_REG_OK = "ok";
export const CARRIER_REG_FAIL = "fail";
export const PRIVACY_POLICY = "https://jambonz.org/privacy";
export const TERMS_OF_SERVICE = "https://jambonz.org/terms";
/** API base paths */
export const API_LOGIN = `${API_BASE_URL}/login`;
export const API_LOGOUT = `${API_BASE_URL}/logout`;
@@ -478,12 +279,3 @@ 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`;
export const API_APP_ENV = `${API_BASE_URL}/AppEnv`;

View File

@@ -26,15 +26,6 @@ import {
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,
API_APP_ENV,
} from "./constants";
import { ROUTE_LOGIN } from "src/router/routes";
import {
@@ -83,30 +74,14 @@ import type {
BucketCredential,
BucketCredentialTestResult,
Client,
RegisterRequest,
RegisterResponse,
ActivationCode,
CurrentUserData,
PriceInfo,
Subscription,
DeleteAccount,
ChangePassword,
SignIn,
GoogleCustomVoice,
GoogleCustomVoicesQuery,
SpeechSupportedLanguagesAndVoices,
AppEnv,
PhoneNumberQuery,
ApplicationQuery,
VoipCarrierQuery,
} from "./types";
import { Availability, StatusCodes } from "./types";
import { 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 {
@@ -188,13 +163,13 @@ const getAuthHeaders = () => {
return {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${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()
);
};
@@ -230,16 +205,6 @@ 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) => {
@@ -250,7 +215,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",
@@ -274,17 +239,6 @@ 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) => {
@@ -306,7 +260,7 @@ export const postLogout = () => {
export const postServiceProviders = (payload: Partial<ServiceProvider>) => {
return postFetch<SidResponse, Partial<ServiceProvider>>(
API_SERVICE_PROVIDERS,
payload,
payload
);
};
@@ -320,24 +274,24 @@ export const postAccount = (payload: Partial<Account>) => {
export const postAccountBucketCredentialTest = (
sid: string,
payload: Partial<BucketCredential>,
payload: Partial<BucketCredential>
) => {
return postFetch<BucketCredentialTestResult, Partial<BucketCredential>>(
`${API_ACCOUNTS}/${sid}/BucketCredentialTest`,
payload,
payload
);
};
export const postApplication = (payload: Partial<Application>) => {
return postFetch<SidResponse, Partial<Application>>(
API_APPLICATIONS,
payload,
payload
);
};
export const postSpeechService = (
sid: string,
payload: Partial<SpeechCredential>,
payload: Partial<SpeechCredential>
) => {
const userData = parseJwt(getToken());
const apiUrl =
@@ -351,14 +305,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
);
};
@@ -374,19 +328,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}`
);
};
@@ -397,45 +351,45 @@ export const postSipGateway = (payload: Partial<SipGateway>) => {
export const postSmppGateway = (payload: Partial<SmppGateway>) => {
return postFetch<SidResponse, Partial<SmppGateway>>(
API_SMPP_GATEWAY,
payload,
payload
);
};
export const postServiceProviderLimit = (
sid: string,
payload: Partial<Limit>,
payload: Partial<Limit>
) => {
return postFetch<SidResponse, Partial<Limit>>(
`${API_SERVICE_PROVIDERS}/${sid}/Limits`,
payload,
payload
);
};
export const postAccountLimit = (sid: string, payload: Partial<Limit>) => {
return postFetch<SidResponse, Partial<Limit>>(
`${API_ACCOUNTS}/${sid}/Limits`,
payload,
payload
);
};
export const postPasswordSettings = (payload: Partial<PasswordSettings>) => {
return postFetch<EmptyResponse, Partial<PasswordSettings>>(
API_PASSWORD_SETTINGS,
payload,
payload
);
};
export const postForgotPassword = (payload: Partial<ForgotPassword>) => {
return postFetch<EmptyResponse, Partial<ForgotPassword>>(
API_FORGOT_PASSWORD,
payload,
payload
);
};
export const postSystemInformation = (payload: Partial<SystemInformation>) => {
return postFetch<SystemInformation, Partial<SystemInformation>>(
API_SYSTEM_INFORMATION,
payload,
payload
);
};
@@ -443,116 +397,59 @@ export const postLcr = (payload: Partial<Lcr>) => {
return postFetch<SidResponse, Partial<Lcr>>(API_LCRS, payload);
};
export const postLcrCreateRoutes = (
sid: string,
payload: Partial<LcrRoute[]>,
) => {
return postFetch<EmptyResponse, Partial<LcrRoute[]>>(
`${API_LCRS}/${sid}/Routes`,
payload,
);
};
export const postLcrRoute = (payload: Partial<LcrRoute>) => {
return postFetch<SidResponse, Partial<LcrRoute>>(API_LCR_ROUTES, payload);
};
export const postLcrCarrierSetEntry = (
payload: Partial<LcrCarrierSetEntry>,
payload: Partial<LcrCarrierSetEntry>
) => {
return postFetch<SidResponse, Partial<LcrCarrierSetEntry>>(
API_LCR_CARRIER_SET_ENTRIES,
payload,
payload
);
};
export const postClient = (payload: Partial<Client>) => {
return postFetch<SidResponse, Partial<Client>>(API_CLIENTS, payload);
};
export const postRegister = (payload: Partial<RegisterRequest>) => {
return postFetch<RegisterResponse, Partial<RegisterRequest>>(
API_REGISTER,
payload,
);
};
export const postSipRealms = (accountSid: string, domain: string) => {
return postFetch<EmptyResponse>(
`${API_ACCOUNTS}/${accountSid}/SipRealms/${domain}`,
);
};
export const postSubscriptions = (payload: Partial<Subscription>) => {
return postFetch<Subscription, Partial<Subscription>>(
API_SUBSCRIPTIONS,
payload,
);
};
export const postChangepassword = (payload: Partial<ChangePassword>) => {
return postFetch<EmptyResponse, Partial<ChangePassword>>(
API_CHANGE_PASSWORD,
payload,
);
};
export const postSignIn = (payload: Partial<SignIn>) => {
return postFetch<SignIn, Partial<SignIn>>(API_SIGNIN, payload);
};
export const postGoogleCustomVoice = (payload: Partial<GoogleCustomVoice>) => {
return postFetch<SidResponse, Partial<GoogleCustomVoice>>(
API_GOOGLE_CUSTOM_VOICES,
payload,
);
};
export const postGoogleVoiceCloningKey = (sid: string, file: File) => {
const formData = new FormData();
formData.append("file", file);
return postBlobFetch<EmptyResponse>(
`${API_GOOGLE_CUSTOM_VOICES}/${sid}/VoiceCloningKey`,
formData,
);
};
/** Named wrappers for `putFetch` */
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
return putFetch<EmptyResponse, Partial<UserUpdatePayload>>(
`${API_USERS}/${sid}`,
payload,
payload
);
};
export const putServiceProvider = (
sid: string,
payload: Partial<ServiceProvider>,
payload: Partial<ServiceProvider>
) => {
return putFetch<EmptyResponse, Partial<ServiceProvider>>(
`${API_SERVICE_PROVIDERS}/${sid}`,
payload,
payload
);
};
export const putAccount = (sid: string, payload: Partial<Account>) => {
return putFetch<EmptyResponse, Partial<Account>>(
`${API_ACCOUNTS}/${sid}`,
payload,
payload
);
};
export const putApplication = (sid: string, payload: Partial<Application>) => {
return putFetch<EmptyResponse, Partial<Application>>(
`${API_APPLICATIONS}/${sid}`,
payload,
payload
);
};
export const putSpeechService = (
sid1: string,
sid2: string,
payload: Partial<SpeechCredential>,
payload: Partial<SpeechCredential>
) => {
const userData = parseJwt(getToken());
const apiUrl =
@@ -565,25 +462,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 =
@@ -597,14 +494,14 @@ export const putCarrier = (
export const putSipGateway = (sid: string, payload: Partial<SipGateway>) => {
return putFetch<EmptyResponse, Partial<SipGateway>>(
`${API_SIP_GATEWAY}/${sid}`,
payload,
payload
);
};
export const putSmppGateway = (sid: string, payload: Partial<SmppGateway>) => {
return putFetch<EmptyResponse, Partial<SmppGateway>>(
`${API_SMPP_GATEWAY}/${sid}`,
payload,
payload
);
};
@@ -612,60 +509,29 @@ export const putLcr = (sid: string, payload: Partial<Lcr>) => {
return putFetch<EmptyResponse, Partial<Lcr>>(`${API_LCRS}/${sid}`, payload);
};
export const putLcrUpdateRoutes = (
sid: string,
payload: Partial<LcrRoute[]>,
) => {
return putFetch<EmptyResponse, Partial<LcrRoute[]>>(
`${API_LCRS}/${sid}/Routes`,
payload,
);
};
export const putLcrRoutes = (sid: string, payload: Partial<LcrRoute>) => {
return putFetch<EmptyResponse, Partial<LcrRoute>>(
`${API_LCR_ROUTES}/${sid}`,
payload,
payload
);
};
export const putLcrCarrierSetEntries = (
sid: string,
payload: Partial<LcrCarrierSetEntry>,
payload: Partial<LcrCarrierSetEntry>
) => {
return putFetch<EmptyResponse, Partial<LcrCarrierSetEntry>>(
`${API_LCR_CARRIER_SET_ENTRIES}/${sid}`,
payload,
payload
);
};
export const putClient = (sid: string, payload: Partial<Client>) => {
return putFetch<EmptyResponse, Partial<Client>>(
`${API_CLIENTS}/${sid}`,
payload,
payload
);
};
export const putActivationCode = (
code: string,
payload: Partial<ActivationCode>,
) => {
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,
);
};
/** Named wrappers for `deleteFetch` */
export const deleteUser = (sid: string) => {
@@ -680,11 +546,8 @@ export const deleteApiKey = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_API_KEYS}/${sid}`);
};
export const deleteAccount = (sid: string, payload: Partial<DeleteAccount>) => {
return deleteFetchWithPayload<EmptyResponse, Partial<DeleteAccount>>(
`${API_ACCOUNTS}/${sid}`,
payload,
);
export const deleteAccount = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_ACCOUNTS}/${sid}`);
};
export const deleteApplication = (sid: string) => {
@@ -693,7 +556,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}`
);
};
@@ -719,16 +582,16 @@ export const deleteSmppGateway = (sid: string) => {
export const deleteServiceProviderLimit = (
sid: string,
cat: LimitCategories,
cat: LimitCategories
) => {
return deleteFetch<EmptyResponse>(
`${API_SERVICE_PROVIDERS}/${sid}/Limits?category=${cat}`,
`${API_SERVICE_PROVIDERS}/${sid}/Limits?category=${cat}`
);
};
export const deleteAccountLimit = (sid: string, cat: LimitCategories) => {
return deleteFetch<EmptyResponse>(
`${API_ACCOUNTS}/${sid}/Limits?category=${cat}`,
`${API_ACCOUNTS}/${sid}/Limits?category=${cat}`
);
};
@@ -751,14 +614,6 @@ export const deleteAccountTtsCache = (sid: string) => {
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) => {
@@ -771,7 +626,7 @@ export const getServiceProviders = () => {
export const getAccountWebhook = (sid: string) => {
return getFetch<SecretResponse>(
`${API_ACCOUNTS}/${sid}/WebhookSecret?regenerate=true`,
`${API_ACCOUNTS}/${sid}/WebhookSecret?regenerate=true`
);
};
@@ -793,7 +648,7 @@ export const getLcrRoute = (sid: string) => {
export const getLcrCarrierSetEtries = (sid: string) => {
return getFetch<LcrCarrierSetEntry[]>(
`${API_LCR_CARRIER_SET_ENTRIES}?lcr_route_sid=${sid}`,
`${API_LCR_CARRIER_SET_ENTRIES}?lcr_route_sid=${sid}`
);
};
@@ -805,69 +660,23 @@ 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}`);
};
// ENV VARS
export const getAppEnvSchema = (url: string) => {
return getFetch<AppEnv>(`${API_APP_ENV}?url=${url}`);
};
export const getApplications = (
sid: string,
query: Partial<ApplicationQuery>,
) => {
const qryStr = getQuery<Partial<ApplicationQuery>>(query);
return getFetch<PagedResponse<Application>>(
`${API_ACCOUNTS}/${sid}/Applications?${qryStr}`,
);
};
export const getSPVoipCarriers = (
sid: string,
query: Partial<VoipCarrierQuery>,
) => {
const qryStr = getQuery<Partial<VoipCarrierQuery>>(query);
return getFetch<PagedResponse<Carrier>>(
`${API_SERVICE_PROVIDERS}/${sid}/VoipCarriers?${qryStr}`,
);
};
/** Wrappers for APIs that can have a mock dev server response */
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>>(
`${API_ACCOUNTS}/${sid}/RecentCalls?${qryStr}`,
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls?${qryStr}`
: `${API_ACCOUNTS}/${sid}/RecentCalls?${qryStr}`
);
};
export const getRecentCall = (sid: string, sipCallId: string) => {
return getFetch<TotalResponse>(
`${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}`,
);
};
export const getRecentCallLog = (sid: string, callSid: string) => {
return getFetch<string[]>(
`${API_ACCOUNTS}/${sid}/RecentCalls/${callSid}/logs`,
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}`
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}`
);
};
@@ -875,7 +684,7 @@ export const getPcap = (sid: string, sipCallId: string, method: string) => {
return getBlob(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`,
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
);
};
@@ -883,30 +692,30 @@ export const getJaegerTrace = (sid: string, traceId: string) => {
return getFetch<JaegerRoot>(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/trace/${traceId}`
: `${API_ACCOUNTS}/${sid}/RecentCalls/trace/${traceId}`,
: `${API_ACCOUNTS}/${sid}/RecentCalls/trace/${traceId}`
);
};
export const getServiceProviderRecentCall = (
sid: string,
sipCallId: string,
sipCallId: string
) => {
return getFetch<TotalResponse>(
import.meta.env.DEV
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}`,
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}`
);
};
export const getServiceProviderPcap = (
sid: string,
sipCallId: string,
method: string,
method: string
) => {
return getBlob(
import.meta.env.DEV
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`,
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
);
};
@@ -916,37 +725,10 @@ 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 getPhoneNumbers = (query: Partial<PhoneNumberQuery>) => {
const qryStr = getQuery<Partial<PhoneNumberQuery>>(query);
return getFetch<PagedResponse<PhoneNumber>>(`${API_PHONE_NUMBERS}?${qryStr}`);
};
export const getSpeechSupportedLanguagesAndVoices = (
sid: string | undefined,
vendor: string,
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 */
@@ -1006,7 +788,7 @@ export const useServiceProviderData: UseApiData = <Type>(apiPath: string) => {
if (currentServiceProvider) {
getFetch<Type>(
`${API_SERVICE_PROVIDERS}/${currentServiceProvider.service_provider_sid}/${apiPath}`,
`${API_SERVICE_PROVIDERS}/${currentServiceProvider.service_provider_sid}/${apiPath}`
)
.then(({ json }) => {
if (!ignore) {

View File

@@ -37,26 +37,14 @@ export interface JaegerAttribute {
value: JaegerValue;
}
export interface WaveSurferSttResult {
export interface WaveSufferSttResult {
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 {
export interface WaveSufferDtmfResult {
dtmf: string;
duration: string;
}

View File

@@ -1,10 +1,4 @@
import type {
JambonzResourceOptions,
Language,
Model,
Vendor,
VoiceLanguage,
} from "src/vendor/types";
import type { Vendor } from "src/vendor/types";
/** Simple types */
@@ -32,8 +26,6 @@ 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"
@@ -71,9 +63,11 @@ 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 */
@@ -130,11 +124,9 @@ export interface ForgotPassword {
}
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;
domain_name: string;
sip_domain_name: string;
monitoring_domain_name: string;
}
export interface TtsCache {
@@ -149,7 +141,6 @@ 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;
@@ -157,7 +148,6 @@ export interface User {
service_provider_name?: string | null;
initial_password?: string;
permissions?: UserPermissions[];
provider?: null | string;
}
export interface UserLogin {
@@ -181,7 +171,6 @@ export interface UserUpdatePayload {
name: string;
force_change: boolean;
is_active: boolean;
is_view_only: boolean;
service_provider_sid: string | null;
account_sid: string | null;
}
@@ -197,8 +186,6 @@ export interface UserJWT {
export interface CurrentUserData {
user: User;
account?: Account;
subscription?: null | Subscription;
}
export interface ServiceProvider {
@@ -258,7 +245,6 @@ 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;
@@ -269,39 +255,6 @@ export interface Account {
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 {
@@ -316,9 +269,6 @@ export interface BucketCredential {
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 {
@@ -326,25 +276,15 @@ 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;
env_vars: null | Record<string, string | number | boolean>;
}
export interface PhoneNumber {
@@ -384,17 +324,6 @@ export interface RecentCall {
recording_url?: string;
}
export interface GoogleCustomVoice {
google_custom_voice_sid?: string;
speech_credential_sid?: string;
name: string;
reported_usage: string;
model?: string;
use_voice_cloning_key: number;
voice_cloning_key?: string | null;
voice_cloning_key_file?: File | null;
}
export interface SpeechCredential {
speech_credential_sid: string;
service_provider_sid: null | string;
@@ -406,20 +335,14 @@ 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;
client_key: null | string;
secret: null | string;
nuance_tts_uri: null | string;
nuance_stt_uri: null | string;
@@ -432,25 +355,6 @@ export interface SpeechCredential {
auth_token: null | string;
custom_stt_url: null | string;
custom_tts_url: null | string;
custom_tts_streaming_url: null | string;
label: null | string;
cobalt_server_uri: null | string;
model_id: null | string;
stt_model_id: null | string;
voice_engine: null | string;
engine_version: null | string;
service_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;
playht_tts_uri: null | string;
resemble_tts_uri: null | string;
resemble_tts_use_tls: number;
api_uri: null | string;
houndify_server_uri: null | string;
}
export interface Alert {
@@ -468,10 +372,6 @@ export interface CarrierRegisterStatus {
callId: null | string;
}
export type DtmfType = "rfc2833" | "tones" | "info";
export type TrunkType = "static_ip" | "auth" | "reg";
export interface Carrier {
voip_carrier_sid: string;
name: string;
@@ -498,9 +398,6 @@ export interface Carrier {
smpp_inbound_password: null | string;
smpp_enquire_link_interval: number;
register_status: CarrierRegisterStatus;
dtmf_type: DtmfType;
outbound_sip_proxy: string | null;
trunk_type: TrunkType;
}
export interface PredefinedCarrier extends Carrier {
@@ -511,6 +408,7 @@ export interface PredefinedCarrier extends Carrier {
export interface Gateway {
voip_carrier_sid: string;
ipv4: string;
port: number;
netmask: number;
inbound: number;
outbound: number;
@@ -520,17 +418,12 @@ 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 {
@@ -547,7 +440,7 @@ export interface LcrRoute {
lcr_route_sid?: null | string;
lcr_sid: null | string;
regex: null | string;
description?: null | string;
desciption?: null | string;
priority: number;
lcr_carrier_set_entries?: LcrCarrierSetEntry[];
}
@@ -566,46 +459,20 @@ export interface Client {
username: null | string;
password?: null | string;
is_active: boolean;
allow_direct_app_calling: boolean;
allow_direct_queue_calling: boolean;
allow_direct_user_calling: boolean;
}
export interface PageQuery {
page: number;
page_size?: number;
count: number;
start?: string;
days?: number;
}
export interface PhoneNumberQuery extends PageQuery {
service_provider_sid?: string;
account_sid?: string;
filter?: string;
}
export interface CallQuery extends PageQuery {
direction?: string;
answered?: string;
}
export interface ApplicationQuery extends PageQuery {
name?: string;
}
export interface VoipCarrierQuery extends PageQuery {
name?: string;
account_sid?: string;
}
export interface GoogleCustomVoicesQuery {
speech_credential_sid?: string;
label?: string;
account_sid?: string;
service_provider_sid: string;
}
export interface PagedResponse<Type> {
page_size: number;
total: number;
@@ -636,211 +503,3 @@ 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;
tiers?: null | Tier[];
type: null | string;
unit_amount: null | 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: null | number;
flat_amount: null | number;
unit_amount: null | number;
flat_amount_decimal: null | string;
unit_amount_decimal: null | string;
}
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 interface InworldOptions {
audioConfig: {
bitRate?: number;
sampleRateHertz?: number;
pitch?: number;
speakingRate?: number;
};
temperature?: number;
}
export type CartesiaEmotions =
| "anger:lowest"
| "anger:low"
| "anger:high"
| "anger:highest"
| "positivity:lowest"
| "positivity:low"
| "positivity:high"
| "positivity:highest"
| "surprise:lowest"
| "surprise:high"
| "surprise:highest"
| "sadness:lowest"
| "sadness:low"
| "curiosity:low"
| "curiosity:high"
| "curiosity:highest";
export interface CartesiaOptions {
speed: number;
emotion: CartesiaEmotions;
}
export interface AppEnvProperty {
description: string;
type: string;
required?: boolean;
default?: string | number | boolean;
obscure?: boolean;
uiHint?: "input" | "textarea" | "filepicker";
enum?: string[];
jambonzResource?: "carriers";
jambonzResourceOptions?: JambonzResourceOptions[];
}
export interface AppEnv {
[key: string]: AppEnvProperty;
}

View File

@@ -43,63 +43,32 @@ describe("<AccountFilter>", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Default value is properly set to first option */
cy.get("input").should("have.value", accountsSorted[0].name);
cy.get("select").should("have.value", accountsSorted[0].account_sid);
});
it("updates value onChange", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Assert onChange value updates */
cy.get("input").clear();
cy.get("input").type(accountsSorted[1].name);
cy.get("input").should("have.value", accountsSorted[1].name);
cy.get("select").select(accountsSorted[1].account_sid);
cy.get("select").should("have.value", accountsSorted[1].account_sid);
});
it("manages the focused state", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Test the `focused` state className (applied onFocus) */
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");
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");
});
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("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");
cy.get("select").should("have.value", "");
});
});

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import React from "react";
import { Icons } from "src/components/icons";
import { useToast } from "../toast/toast-provider";
import { toastError, toastSuccess } from "src/store";
type ClipBoardProps = {
id?: string;
@@ -13,7 +13,6 @@ type ClipBoardProps = {
const hasClipboard = typeof navigator.clipboard !== "undefined";
export const ClipBoard = ({ text, id = "", name = "" }: ClipBoardProps) => {
const { toastSuccess, toastError } = useToast();
const handleClick = () => {
navigator.clipboard
.writeText(text)
@@ -21,7 +20,7 @@ export const ClipBoard = ({ text, id = "", name = "" }: ClipBoardProps) => {
toastSuccess(
<>
<strong>{text}</strong> copied to clipboard
</>,
</>
);
})
.catch(() => {
@@ -29,7 +28,7 @@ export const ClipBoard = ({ text, id = "", name = "" }: ClipBoardProps) => {
<>
Unable to copy <strong>{text}</strong>, please select the text and
right click to copy
</>,
</>
);
});
};

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useEffect, forwardRef } from "react";
import { TypeaheadSelector } from "src/components/forms";
import { Selector } 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 = HTMLInputElement;
type SelectorRef = HTMLSelectElement;
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>
<TypeaheadSelector
<Selector
ref={ref}
id="account_sid"
name="account_sid"
@@ -56,14 +56,14 @@ export const AccountSelect = forwardRef<SelectorRef, AccountSelectProps>(
name: account.name,
value: account.account_sid,
}))
: [],
: []
)}
onChange={(e) => setAccountSid(e.target.value)}
{...restProps}
/>
</>
);
},
}
);
AccountSelect.displayName = "AccountSelect";

View File

@@ -1,6 +1,6 @@
import React, { useEffect, forwardRef } from "react";
import { TypeaheadSelector } from "src/components/forms";
import { Selector } 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 = HTMLInputElement;
type SelectorRef = HTMLSelectElement;
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>
<TypeaheadSelector
<Selector
ref={ref}
id={id}
name={id}
@@ -62,14 +62,14 @@ export const ApplicationSelect = forwardRef<
name: application.name,
value: application.application_sid,
}))
: [],
: []
)}
onChange={(e) => setApplicationSid(e.target.value)}
{...restProps}
/>
</>
);
},
}
);
ApplicationSelect.displayName = "ApplicationSelect";

View File

@@ -7,11 +7,9 @@ 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;
};
@@ -24,14 +22,12 @@ 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({
@@ -51,30 +47,26 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
return (
<div className={classesTop}>
<label>
<div className="label-container">
<input
disabled={disabled}
ref={ref}
type="checkbox"
name={name}
id={id || name}
onChange={(e) => {
setChecked(e.target.checked);
<input
ref={ref}
type="checkbox"
name={name}
id={id || name}
onChange={(e) => {
setChecked(e.target.checked);
if (handleChecked) {
handleChecked(e);
}
}}
checked={checked}
/>
{label && <div>{label}</div>}
{labelNode && labelNode}
</div>
if (handleChecked) {
handleChecked(e);
}
}}
checked={checked}
/>
<div>{label}</div>
</label>
{checked && <div className={classesIn}>{children}</div>}
</div>
);
},
}
);
Checkzone.displayName = "Checkzone";

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ 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,
@@ -18,5 +17,4 @@ export {
ApplicationSelect,
LocalLimits,
useLocalLimitsRef,
TypeaheadSelector,
};

View File

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

View File

@@ -8,8 +8,6 @@ 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;
@@ -24,27 +22,16 @@ 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}
@@ -56,7 +43,6 @@ export const Passwd = forwardRef<PasswdRef, PasswdProps>(
}
}}
{...restProps}
{...passwordManagerAttributes}
/>
{!locked && (
<button
@@ -69,7 +55,7 @@ export const Passwd = forwardRef<PasswdRef, PasswdProps>(
)}
</div>
);
},
}
);
Passwd.displayName = "Passwd";

View File

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

View File

@@ -25,6 +25,7 @@
}
select {
@include ui-mixins.m();
appearance: none;
padding: ui-vars.$px01 ui-vars.$px02;
border-radius: ui-vars.$px01;
@@ -32,7 +33,6 @@
background-color: ui-vars.$white;
width: 100%;
max-width: vars.$widthinput;
@include ui-mixins.m();
&:focus {
border-color: ui-vars.$dark;

View File

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

View File

@@ -1,182 +0,0 @@
@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 {
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;
@include ui-mixins.m();
&: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 {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: ui-vars.$white;
border: 1px solid ui-vars.$dark;
max-height: 200px;
overflow-y: auto;
@include ui-mixins.m();
}
@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 {
width: 100%;
@include typeahead-input();
}
span {
@include typeahead-span();
}
.typeahead-dropdown {
z-index: 1000;
@include typeahead-dropdown();
}
.typeahead-option {
@include typeahead-option();
}
}
.typeahead-selectorsmall {
@include typeahead-base();
width: auto;
input {
height: 34px;
min-width: 370px;
font-size: var(--mxs-size);
@include typeahead-input();
}
span {
@include typeahead-span();
}
.typeahead-dropdown {
width: 100%;
@include typeahead-dropdown();
}
.typeahead-option {
font-size: var(--mxs-size);
@include typeahead-option();
}
.pointerevents {
pointer-events: all;
cursor: default;
}
}
.filters--multi {
overflow-x: visible !important;
white-space: nowrap;
grid-gap: 16px;
}

View File

@@ -48,9 +48,6 @@ import {
ChevronsRight,
Download,
Smartphone,
Youtube,
Mail,
Tag,
} from "react-feather";
import type { Icon } from "react-feather";
@@ -109,7 +106,4 @@ export const Icons: IconMap = {
ChevronsRight,
Download,
Smartphone,
Youtube,
Mail,
Tag,
};

View File

@@ -3,7 +3,6 @@ import ReactDOM from "react-dom";
import { Button, ButtonGroup } from "@jambonz/ui-kit";
import "./styles.scss";
import { Spinner } from "../spinner";
type ModalProps = {
disabled?: boolean;
@@ -50,7 +49,7 @@ export const Modal = ({
</ButtonGroup>
</div>
</div>,
portal,
portal
);
};
@@ -70,7 +69,6 @@ export const ModalForm = ({
}}
>
<div className="modal__stuff">{children}</div>
<ButtonGroup right>
<Button
small
@@ -87,7 +85,7 @@ export const ModalForm = ({
</ButtonGroup>
</form>
</div>,
portal,
portal
);
};
@@ -113,40 +111,6 @@ export const ModalClose = ({ children, handleClose }: CloseProps) => {
</ButtonGroup>
</div>
</div>,
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,
portal
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React from "react";
export const TOAST_TIME = 5000;
export const TOAST_TIME = 3000;
export const SESS_FLASH_MSG = "SESS_FLASH_MSG";
export const SESS_USER_SID = "SESS_USER_SID";
export const SESS_OLD_PASSWORD = "SESS_OLD_PASSWORD";
@@ -24,3 +24,4 @@ export const MSG_WEBHOOK_FIELDS = (
<span>password</span> fields are required.
</>
);
export const NOT_AVAILABLE_PREFIX = "NotAvalable";

View File

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

View File

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

View File

@@ -18,10 +18,7 @@ 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";
import { DISABLE_LCR } from "src/api/constants";
export interface NaviItem {
label: string;
@@ -33,17 +30,11 @@ export interface NaviItem {
}
export const naviTop: NaviItem[] = [
// User is not allowed in hosted app
...(!ENABLE_HOSTED_SYSTEM
? [
{
label: "Users",
icon: Icons.UserCheck,
route: () => ROUTE_INTERNAL_USERS,
},
]
: []),
{
label: "Users",
icon: Icons.UserCheck,
route: () => ROUTE_INTERNAL_USERS,
},
{
label: "Settings",
icon: Icons.Settings,

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { useParams } from "react-router-dom";
import { ApiKeys } from "src/containers/internal/api-keys";
import { useApiData } from "src/api";
import { useSelectState } from "src/store";
import { toastError, useSelectState } from "src/store";
import { AccountForm } from "./form";
import type { Account, Application, Limit, TtsCache } from "src/api/types";
@@ -14,21 +14,19 @@ import {
} from "src/router/routes";
import { useScopedRedirect } from "src/utils";
import { Scope } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
export const EditAccount = () => {
const { toastError } = useToast();
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Account>(
`Accounts/${params.account_sid}`,
`Accounts/${params.account_sid}`
);
const [limitsData, refetchLimits] = useApiData<Limit[]>(
`Accounts/${params.account_sid}/Limits`,
`Accounts/${params.account_sid}/Limits`
);
const [apps] = useApiData<Application[]>("Applications");
const [ttsCache, ttsCacheFetcher] = useApiData<TtsCache>(
`Accounts/${params.account_sid}/TtsCache`,
`Accounts/${params.account_sid}/TtsCache`
);
useScopedRedirect(
@@ -38,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... */

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import { useServiceProviderData, deleteAccount } from "src/api";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import { Section, Icons, Spinner, SearchFilter } from "src/components";
import { DeleteAccount } from "./delete";
import { useSelectState } from "src/store";
import { toastError, toastSuccess, useSelectState } from "src/store";
import {
hasLength,
hasValue,
@@ -17,10 +17,8 @@ import { USER_ACCOUNT } from "src/api/constants";
import { Scope } from "src/store/types";
import type { Account } from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
export const Accounts = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [accounts, refetch] = useServiceProviderData<Account[]>("Accounts");
const [account, setAccount] = useState<Account | null>(null);
@@ -32,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 = () => {
@@ -42,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) => {
@@ -73,7 +71,7 @@ export const Accounts = () => {
</Icon>
</Link>
</section>
<section className="filters filters--multi">
<section className="filters filters--spaced">
<SearchFilter
placeholder="Filter accounts"
filter={[filter, setFilter]}

View File

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

View File

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

View File

@@ -1,655 +0,0 @@
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 { ModalLoader } from "src/components/modal";
import { useToast } from "src/components/toast/toast-provider";
const SubscriptionForm = () => {
const { toastError, toastSuccess } = useToast();
const [userData] = useApiData<CurrentUserData>("Users/me");
const [priceInfo] = useApiData<PriceInfo[]>("/Prices");
const [userStripeInfo] = useApiData<StripeCustomerId>("/StripeCustomerId");
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: ServiceData[],
) => {
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 || 0) * 1) / 100;
break;
case "tiered":
if (price.tiers && price.tiers.length) {
const tier = price.tiers.find(
(item) => !item.up_to || item.up_to >= service.capacity,
);
if (tier) {
if (typeof tier.flat_amount === "number") {
fees = tier.flat_amount / 100;
} else {
fees = ((tier.unit_amount || 0) * 1) / 100;
}
}
service.tiers = price.tiers;
}
break;
default:
break;
}
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.cost = fees * service.capacity;
service.feesLabel = `${
CurrencySymbol[service.currency || "usd"]
}${fees} per ${
record.unit_label?.slice(0, 3) === "per"
? record.unit_label.slice(3)
: record.unit_label
}`;
}
}
});
return [...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 || 0) / 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],
) => {
let serviceD = serviceData.map((g, i) =>
i === index
? {
...g,
[key]: value,
}
: g,
);
if (key === "capacity" && priceInfo) {
serviceD = initFeesAndCost(priceInfo, serviceD);
}
setServiceData([...serviceD]);
};
useEffect(() => {
if (priceInfo) {
setServiceData(initFeesAndCost(priceInfo, serviceData));
}
if (userData && priceInfo) {
setProductsInfo(userData);
}
}, [priceInfo, userData]);
useEffect(() => {
if (isModifySubscription && originalServiceData.length > 0) {
setIsDisableSubmitButton(
serviceData[0].capacity === originalServiceData[0].capacity &&
serviceData[1].capacity === originalServiceData[1].capacity,
);
}
setTotal(serviceData.reduce((res, service) => res + service.cost || 0, 0));
}, [serviceData]);
return (
<>
<H1 className="h2">
{isModifySubscription
? "Configure Your Subscription"
: "Upgrade your Subscription"}
</H1>
{isShowModalLoader && (
<ModalLoader>
<P>
Your requested changes are being processed. Please do not leave the
page or hit the back button until complete.
</P>
</ModalLoader>
)}
{isReviewChanges && !isShowModalLoader && (
<Modal
handleCancel={() => {
setIsReviewChanges(false);
setIsDisableSubmitButton(false);
}}
handleSubmit={handleReviewChangeSubmit}
>
<H1 className="h4">Confirm Changes</H1>
<P>
By pressing{" "}
<span>
<strong>Confirm</strong>
</span>{" "}
below, your plan will be immediately adjusted to the following
levels:
</P>
<ul className="m">
<li>{`- ${serviceData[0].capacity} simultaneous calls`}</li>
{userData?.account && userData?.account.device_to_call_ratio && (
<li>{`- ${
userData?.account.device_to_call_ratio *
(serviceData[0].capacity + serviceData[1].capacity)
} registered devices`}</li>
)}
</ul>
<P>
{(billingCharge?.prorated_cost || 0) > 0 &&
`Your new monthly charge will be $${
(billingCharge?.monthly_cost || 0) / 100
}, and you will immediately be charged a one-time prorated amount of $${
(billingCharge?.prorated_cost || 0) / 100
} to cover the remainder of the current billing period.`}
{billingCharge?.prorated_cost === 0 &&
`Your monthly charge will be $${
(billingCharge.monthly_cost || 0) / 100
}.`}
{(billingCharge?.prorated_cost || 0) < 0 &&
`Your new monthly charge will be $${
(billingCharge?.monthly_cost || 0) / 100
}, and you will receive a credit of $${
-(billingCharge?.prorated_cost || 0) / 100
} on your next invoice to reflect changes made during the current billing period.`}
</P>
</Modal>
)}
{isReturnToFreePlan && !isShowModalLoader && (
<Modal
handleCancel={() => setIsReturnToFreePlan(false)}
handleSubmit={handleReturnToFreePlan}
>
<H1 className="h4">Return to Free Plan</H1>
<P>
Returning to the free plan will reduce your capacity to a maximum of
1 simultaneous call session and 1 registered device. Your current
plan and capacity will continue through the rest of the billing
cycle and your plan change will take effect at the beginning of the
next billing cycle. Are you sure you want to continue?
</P>
</Modal>
)}
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<div className="grid grid--col4--users">
<div className="grid__row grid__th">
<div>Service</div>
<div>Capacity</div>
<div>Price</div>
<div>Cost</div>
</div>
{serviceData &&
serviceData
.filter((service) => service.visible)
.map((service, idx) => (
<React.Fragment key={`subscription-${idx}`}>
<div className="grid__row">
<div>
<label htmlFor={service.name || ""}>
{service.service}
<span>*</span>
</label>
</div>
<div>
<input
id="tech_prefix"
name="tech_prefix"
type="number"
value={service.capacity}
required
min={service.min}
max={service.max}
onChange={(e) => {
updateServiceData(
idx,
"capacity",
e.target.value ? Number(e.target.value) : "",
);
}}
/>
</div>
<div>
<em>{service.feesLabel}</em>
</div>
<div>
<P>
<strong>
{CurrencySymbol[service.currency || "usd"]}
{service.cost}
</strong>
</P>
</div>
</div>
</React.Fragment>
))}
{serviceData[0].capacity !== 0 && !serviceData[1].visible && (
<>
<div className="grid__row">
<label htmlFor="max_concurrent_call_sessons">
{`With ${
serviceData[0].capacity
} call sessions you can register ${
serviceData[0].capacity *
(userData?.account?.device_to_call_ratio || 0)
} concurrent devices`}
</label>
<div>
<Button
mainStyle="hollow"
onClick={() =>
setServiceData((prev) => {
prev[1].visible = true;
return [...prev];
})
}
>
Would you like to purchase additional device
registrations?
</Button>
</div>
</div>
</>
)}
<div className="grid__row">
<div>
<label htmlFor="total">Total Monthly Cost</label>
</div>
<div></div>
<div></div>
<div>
<P>
<strong>
{CurrencySymbol[serviceData[0].currency || "usd"]}
{total}
</strong>
</P>
</div>
</div>
{!isModifySubscription && (
<fieldset>
<label htmlFor="total">Payment Information</label>
<div className="grid__row">
<div></div>
<div>
<PaymentElement
options={{
paymentMethodOrder: ["card"],
}}
/>
</div>
</div>
</fieldset>
)}
</div>
<fieldset>
<>
<div className={isModifySubscription ? "mast" : ""}>
{isModifySubscription && (
<ButtonGroup right>
<Button
type="button"
subStyle="grey"
mainStyle="hollow"
onClick={() => setIsReturnToFreePlan(true)}
small
>
Return to free plan
</Button>
</ButtonGroup>
)}
<ButtonGroup right>
<Button
subStyle="grey"
as={Link}
to={`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`}
small
>
Cancel
</Button>
<Button type="submit" disabled={isDisableSubmitButton} small>
{isModifySubscription
? "Review Changes"
: `Pay ${CurrencySymbol[serviceData[0].currency || "usd"]}
${total} and Upgrade to Paid Plan`}
</Button>
</ButtonGroup>
</div>
</>
</fieldset>
</form>
</Section>
</>
);
};
export default SubscriptionForm;

View File

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

View File

@@ -8,7 +8,7 @@ import {
PER_PAGE_SELECTION,
USER_ACCOUNT,
} from "src/api/constants";
import { useSelectState } from "src/store";
import { toastError, useSelectState } from "src/store";
import { hasLength, hasValue } from "src/utils";
import {
AccountFilter,
@@ -27,10 +27,8 @@ import {
setLocation,
} from "src/store/localStore";
import AlertDetailItem from "./alert-detail-item";
import { useToast } from "src/components/toast/toast-provider";
export const Alerts = () => {
const { toastError } = useToast();
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
@@ -49,12 +47,7 @@ export const Alerts = () => {
count: Number(perPageFilter),
...(dateFilter === "today"
? { start: dayjs().startOf("date").toISOString() }
: dateFilter === "yesterday"
? {
start: dayjs().subtract(1, "day").startOf("day").toISOString(),
end: dayjs().subtract(1, "day").endOf("day").toISOString(),
}
: { days: Number(dateFilter) }),
: { days: Number(dateFilter) }),
};
getAlerts(accountSid, payload)
@@ -70,10 +63,10 @@ export const Alerts = () => {
};
useMemo(() => {
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
if (getQueryFilter()) {
const [date] = getQueryFilter().split("/");
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
setDateFilter(date);
}
}, [accountSid]);
@@ -110,7 +103,7 @@ export const Alerts = () => {
id="date_filter"
label="Date"
filter={[dateFilter, setDateFilter]}
options={DATE_SELECTION}
options={DATE_SELECTION.slice(0, 2)}
/>
</section>
<Section {...(hasLength(alerts) && { slim: true })}>

View File

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

View File

@@ -3,21 +3,19 @@ import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";
import { useSelectState } from "src/store";
import { toastError, useSelectState } from "src/store";
import { ApplicationForm } from "./form";
import type { Application } from "src/api/types";
import { useScopedRedirect } from "src/utils/use-scoped-redirect";
import { Scope } from "src/store/types";
import { ROUTE_INTERNAL_APPLICATIONS } from "src/router/routes";
import { useToast } from "src/components/toast/toast-provider";
export const EditApplication = () => {
const { toastError } = useToast();
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Application>(
`Applications/${params.application_sid}`,
`Applications/${params.application_sid}`
);
useScopedRedirect(
@@ -25,7 +23,7 @@ export const EditApplication = () => {
ROUTE_INTERNAL_APPLICATIONS,
user,
"You do not have access to this resource",
data,
data
);
useEffect(() => {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,657 +0,0 @@
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 { useToast } from "src/components/toast/toast-provider";
import { 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,
VENDOR_INWORLD,
VENDOR_DEEPGRAM_FLUX,
VENDOR_RESEMBLE,
VENDOR_HOUNDIFY,
} from "src/vendor";
import {
LabelOptions,
RecognizerVendors,
SynthesisVendors,
VendorOptions,
} from "src/vendor/types";
type SpeechProviderSelectionProbs = {
accountSid: string;
serviceProviderSid: string;
application_speech_synthesis_voice: string | null | undefined;
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,
application_speech_synthesis_voice,
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 { toastError } = useToast();
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,
application_speech_synthesis_voice,
]);
// 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 &&
(!application_speech_synthesis_voice ||
!json.models.some(
(m) => m.value === application_speech_synthesis_voice,
))
) {
setSynthVoice(json.models[0].value);
}
}
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) {
let newLang = json.tts.find((lang) => lang.value === "eng");
// If the new language doesn't map then default to the first one
if (!newLang) {
newLang = json.tts[0];
}
updateTtsVoice(newLang!.value, newLang!.voices[0].value);
return;
}
if (synthVendor === VENDOR_INWORLD) {
let newLang = json.tts.find((lang) => lang.value === "en");
// If the new language doesn't map then default to the first one
if (!newLang) {
newLang = json.tts[0];
}
updateTtsVoice(newLang!.value, newLang!.voices[0].value);
return;
}
/** Google and AWS have different language lists */
/** 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 = () => {
if (recogVendor === VENDOR_DEEPGRAM_FLUX) {
return;
}
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_DEEPGRAM_FLUX &&
vendor.value !== VENDOR_HOUNDIFY &&
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_RESEMBLE &&
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) &&
recogVendor !== VENDOR_DEEPGRAM_FLUX &&
recogLang && (
<>
<label htmlFor="recognizer_lang">Language</label>
<Selector
id="recognizer_lang"
name="recognizer_lang"
value={recogLang}
options={recogLanguageOptions}
onChange={(e) => {
setRecogLang(e.target.value);
}}
/>
</>
)}
{recogVendor.toString().startsWith(VENDOR_CUSTOM) && (
<>
<label htmlFor="custom_vendor_recognizer_voice">Language</label>
<input
id="custom_vendor_recognizer_voice"
type="text"
name="custom_vendor_recognizer_voice"
placeholder="Required"
required
value={recogLang}
onChange={(e) => {
setRecogLang(e.target.value);
}}
/>
</>
)}
</fieldset>
</>
);
};
export default SpeechProviderSelection;

View File

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

View File

@@ -3,27 +3,25 @@ import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";
import { useSelectState } from "src/store";
import { toastError, useSelectState } from "src/store";
import { CarrierForm } from "./form";
import { Carrier, SipGateway, SmppGateway } from "src/api/types";
import { useScopedRedirect } from "src/utils/use-scoped-redirect";
import { ROUTE_INTERNAL_CARRIERS } from "src/router/routes";
import { Scope } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
export const EditCarrier = () => {
const { toastError } = useToast();
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Carrier>(
`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(
@@ -31,7 +29,7 @@ export const EditCarrier = () => {
ROUTE_INTERNAL_CARRIERS,
user,
"You do not have access to this resource",
data,
data
);
useEffect(() => {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,16 +1,15 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useMemo, useEffect } from "react";
import { Link } from "react-router-dom";
import { Button, ButtonGroup, H1, Icon, M, MS } from "@jambonz/ui-kit";
import { Button, H1, Icon, M } from "@jambonz/ui-kit";
import {
deleteCarrier,
deleteSipGateway,
deleteSmppGateway,
getFetch,
getSPVoipCarriers,
useApiData,
useServiceProviderData,
} from "src/api";
import { useSelectState } from "src/store";
import { toastSuccess, toastError, useSelectState } from "src/store";
import { ROUTE_INTERNAL_CARRIERS } from "src/router/routes";
import {
AccountFilter,
@@ -18,90 +17,56 @@ import {
Section,
Spinner,
SearchFilter,
Pagination,
SelectFilter,
} from "src/components";
import { ScopedAccess } from "src/components/scoped-access";
import { Gateways } from "./gateways";
import { isUserAccountScope, hasLength, hasValue } from "src/utils";
import {
isUserAccountScope,
hasLength,
hasValue,
useFilteredResults,
} from "src/utils";
import {
API_SIP_GATEWAY,
API_SMPP_GATEWAY,
CARRIER_REG_OK,
ENABLE_HOSTED_SYSTEM,
PER_PAGE_SELECTION,
USER_ACCOUNT,
ADMIN_CARRIER,
USER_ADMIN,
USER_SP,
} from "src/api/constants";
import { DeleteCarrier } from "./delete";
import type {
Account,
Carrier,
CurrentUserData,
SipGateway,
SmppGateway,
} from "src/api/types";
import type { Account, Carrier, SipGateway, SmppGateway } from "src/api/types";
import { Scope } from "src/store/types";
import { getAccountFilter, setLocation } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
export const Carriers = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [userData] = useApiData<CurrentUserData>("Users/me");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [apiUrl, setApiUrl] = useState("");
const [carrier, setCarrier] = useState<Carrier | null>(null);
const [carriers, setCarriers] = useState<Carrier[] | null>(null);
const [carriers, refetch] = useApiData<Carrier[]>(apiUrl);
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
const [filter, setFilter] = useState("");
const [carriersTotal, setCarriersTotal] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [perPageFilter, setPerPageFilter] = useState("25");
const [maxPageNumber, setMaxPageNumber] = useState(1);
// Add a ref to track previous values
const prevValuesRef = useRef({
serviceProviderId: "",
accountSid: "",
filter: "",
pageNumber: 1,
perPageFilter: "25",
});
const fetchCarriers = (resetPage = false) => {
if (!currentServiceProvider) return;
setCarriers(null);
// Calculate the correct page to use
const currentPage = resetPage ? 1 : pageNumber;
// If we're resetting the page, also update the state
if (resetPage && pageNumber !== 1) {
setPageNumber(1);
const carriersFiltered = useMemo(() => {
setAccountSid(getAccountFilter());
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
}
getSPVoipCarriers(currentServiceProvider.service_provider_sid, {
page: currentPage,
page_size: Number(perPageFilter),
...(filter && { name: filter }),
...(accountSid && { account_sid: accountSid }),
})
.then(({ json }) => {
setCarriers(json.data);
setCarriersTotal(json.total);
setMaxPageNumber(Math.ceil(json.total / Number(perPageFilter)));
})
.catch((error) => {
setCarriers([]);
toastError(error.msg);
});
};
return carriers
? carriers.filter((carrier) =>
accountSid
? carrier.account_sid === accountSid
: carrier.account_sid === null
)
: [];
}, [accountSid, carrier, carriers]);
const filteredCarriers = useFilteredResults<Carrier>(
filter,
carriersFiltered
);
const handleDelete = () => {
if (carrier) {
@@ -114,10 +79,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) &&
@@ -126,8 +91,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(
@@ -135,16 +100,16 @@ export const Carriers = () => {
g &&
g.smpp_gateway_sid &&
deleteSmppGateway(g.smpp_gateway_sid).catch((error) =>
toastError(error.msg),
),
toastError(error.msg)
)
);
});
setCarrier(null);
fetchCarriers(false);
refetch();
toastSuccess(
<>
Deleted Carrier <strong>{carrier.name}</strong>
</>,
</>
);
})
.catch((error) => {
@@ -153,74 +118,30 @@ export const Carriers = () => {
}
};
// Initial account setup
useEffect(() => {
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
} else {
setAccountSid(getAccountFilter());
}
setLocation();
}, [user, accounts]);
// Combined effect for all data fetching
useEffect(() => {
if (!currentServiceProvider) return;
const prevValues = prevValuesRef.current;
const currentSPId = currentServiceProvider.service_provider_sid;
// Determine if we should reset pagination
const isFilterOrProviderChange =
prevValues.serviceProviderId !== currentSPId ||
prevValues.accountSid !== accountSid ||
prevValues.filter !== filter;
const isPageSizeChange =
prevValues.perPageFilter !== perPageFilter &&
prevValues.perPageFilter !== "25"; // Skip initial render
// Update ref for next comparison
prevValuesRef.current = {
serviceProviderId: currentSPId,
accountSid,
filter,
pageNumber,
perPageFilter,
};
// Fetch data with page reset if filters changed
fetchCarriers(isFilterOrProviderChange || isPageSizeChange);
}, [currentServiceProvider, accountSid, filter, pageNumber, perPageFilter]);
if (currentServiceProvider) {
setApiUrl(
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`
);
}
}, [user, currentServiceProvider, accountSid]);
return (
<>
<section className="mast">
<div>
<H1 className="h2">Carriers</H1>
{ENABLE_HOSTED_SYSTEM && (
<M>
Have your carrier send calls to{" "}
<span>{userData?.account?.sip_realm}</span>
</M>
)}
</div>
{((ADMIN_CARRIER === "1" &&
(user?.scope === USER_ADMIN || user?.scope === USER_SP)) ||
ADMIN_CARRIER === "0") && (
<Link to={`${ROUTE_INTERNAL_CARRIERS}/add`} title="Add a Carrier">
{" "}
<Icon>
<Icons.Plus />
</Icon>
</Link>
)}
<H1 className="h2">Carriers</H1>
<Link to={`${ROUTE_INTERNAL_CARRIERS}/add`} title="Add a Carrier">
{" "}
<Icon>
<Icons.Plus />
</Icon>
</Link>
</section>
<section className="filters filters--multi">
<section className="filters filters--spaced">
<SearchFilter
placeholder="Filter carriers"
filter={[filter, setFilter]}
delay={1000}
/>
<ScopedAccess user={user} scope={Scope.service_provider}>
<AccountFilter
@@ -231,12 +152,12 @@ export const Carriers = () => {
/>
</ScopedAccess>
</section>
<Section {...(hasLength(carriers) && { slim: true })}>
<Section {...(hasLength(filteredCarriers) && { slim: true })}>
<div className="list">
{!hasValue(carriers) && hasLength(accounts) ? (
<Spinner />
) : hasLength(carriers) ? (
carriers.map((carrier) => (
) : hasLength(filteredCarriers) ? (
filteredCarriers.map((carrier) => (
<div className="item" key={carrier.voip_carrier_sid}>
<div className="item__info">
<div className="item__title">
@@ -331,35 +252,11 @@ export const Carriers = () => {
)}
</div>
</Section>
{((ADMIN_CARRIER === "1" &&
(user?.scope === USER_ADMIN || user?.scope === USER_SP)) ||
ADMIN_CARRIER === "0") && (
<Section clean>
<Button small as={Link} to={`${ROUTE_INTERNAL_CARRIERS}/add`}>
Add carrier
</Button>
</Section>
)}
<footer>
<ButtonGroup>
<MS>
Total: {carriersTotal} record
{carriersTotal === 1 ? "" : "s"}
</MS>
{hasLength(carriers) && (
<Pagination
pageNumber={pageNumber}
setPageNumber={setPageNumber}
maxPageNumber={maxPageNumber}
/>
)}
<SelectFilter
id="page_filter"
filter={[perPageFilter, setPerPageFilter]}
options={PER_PAGE_SELECTION}
/>
</ButtonGroup>
</footer>
<Section clean>
<Button small as={Link} to={`${ROUTE_INTERNAL_CARRIERS}/add`}>
Add carrier
</Button>
</Section>
{carrier && (
<DeleteCarrier
carrier={carrier}

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import ClientsForm from "./form";
export const ClientsAdd = () => {
return (
<>
<H1 className="h2">Add sip client</H1>
<H1 className="h2">Add client</H1>
<ClientsForm />
</>
);

View File

@@ -17,7 +17,7 @@ export const ClientsDelete = ({
<>
<Modal handleCancel={handleCancel} handleSubmit={handleSubmit}>
<P>
Are you sure you want to delete the sip client{" "}
Are you sure you want to delete the client{" "}
<strong>{client.username}</strong>?
</P>
</Modal>

View File

@@ -1,31 +1,27 @@
import { H1 } from "@jambonz/ui-kit";
import React, { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";
import { Client } from "src/api/types";
import { toastError } from "src/store";
import ClientsForm from "./form";
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
import { useToast } from "src/components/toast/toast-provider";
export const ClientsEdit = () => {
const { toastError } = useToast();
const params = useParams();
const navigate = useNavigate();
const [data, refetch, error] = useApiData<Client>(
`Clients/${params.client_sid}`,
`Clients/${params.client_sid}`
);
/** Handle error toast at top level... */
useEffect(() => {
if (error) {
toastError(error.msg);
navigate(ROUTE_INTERNAL_CLIENTS);
}
}, [error]);
return (
<>
<H1 className="h2">Edit sip client</H1>
<H1 className="h2">Edit client</H1>
<ClientsForm client={{ data, refetch, error }} />
</>
);

View File

@@ -9,22 +9,20 @@ import {
} 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 { Section } 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 { useSelectState } from "src/store";
import { toastError, toastSuccess, useSelectState } from "src/store";
import ClientsDelete from "./delete";
import { hasValue } from "src/utils";
import { IMessage } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
type ClientsFormProps = {
client?: UseApiDataMap<Client>;
};
export const ClientsForm = ({ client }: ClientsFormProps) => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const navigate = useNavigate();
@@ -32,12 +30,7 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
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 [isActive, setIsActive] = useState(true);
const [modal, setModal] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const handleSubmit = (e: React.FormEvent) => {
@@ -49,9 +42,6 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
username: username,
password: password,
is_active: isActive,
allow_direct_app_calling: allowDirectAppCalling,
allow_direct_queue_calling: allowDirectQueueCalling,
allow_direct_user_calling: allowDirectUserCalling,
})
.then(() => {
toastSuccess("Client created successfully");
@@ -62,10 +52,10 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
});
} else {
putClient(client.data?.client_sid || "", {
account_sid: accountSid,
username: username,
...(password && { password: password }),
is_active: isActive,
allow_direct_app_calling: allowDirectAppCalling,
allow_direct_queue_calling: allowDirectQueueCalling,
allow_direct_user_calling: allowDirectUserCalling,
})
.then(() => {
toastSuccess("Client updated successfully");
@@ -109,9 +99,6 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
}
setIsActive(client.data.is_active);
setAllowDirectAppCalling(client.data.allow_direct_app_calling);
setAllowDirectQueueCalling(client.data.allow_direct_queue_calling);
setAllowDirectUserCalling(client.data.allow_direct_user_calling);
}
}, [client]);
@@ -120,6 +107,8 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
if (!accountSid || !accounts || !acc) return;
if (!acc?.sip_realm) {
setErrorMessage(`Sip realm is not set for the account.`);
} else if (!acc?.device_calling_application_sid) {
setErrorMessage(`Device calling application is not set for the account.`);
} else {
setErrorMessage("");
}
@@ -127,12 +116,7 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
return (
<>
<Section slim>
<form
className={`form form--internal ${
!client?.data && client?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<form className="form form--internal" onSubmit={handleSubmit}>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
{errorMessage && <Message message={errorMessage} />}
@@ -150,27 +134,10 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
placeholder="user name"
value={username}
required={true}
disabled={hasValue(client)}
autoComplete="off"
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</div>
<label htmlFor="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"
@@ -181,52 +148,26 @@ export const ClientsForm = ({ client }: ClientsFormProps) => {
/>
<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>
</fieldset>
<fieldset>
<label htmlFor="password">
Password{!hasValue(client) && <span>*</span>}
</label>
<Passwd
id="password"
required={!hasValue(client)}
name="password"
value={password}
placeholder="Password"
setValue={setPassword}
/>
</fieldset>
{user?.scope !== USER_ACCOUNT && (
<fieldset>
<AccountSelect
accounts={accounts}
account={[accountSid, setAccountSid]}
label="Belongs to"
label="Used by"
required={true}
defaultOption={false}
disabled={hasValue(client)}

View File

@@ -2,7 +2,7 @@ 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 { Account, Client } from "src/api/types";
import {
AccountFilter,
Icons,
@@ -12,51 +12,33 @@ import {
Spinner,
} from "src/components";
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
import { useSelectState } from "src/store";
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";
import { useToast } from "src/components/toast/toast-provider";
import { getAccountFilter } from "src/store/localStore";
export const Clients = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [userData] = useApiData<CurrentUserData>("Users/me");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
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(() => {
setAccountSid(getAccountFilter() || accountSid);
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;
return accountSid ? c.account_sid === accountSid : true;
})
: [];
}, [accountSid, clients, accounts]);
}, [accountSid, clients]);
const filteredClients = useFilteredResults(filter, tmpFilteredClients);
@@ -66,8 +48,8 @@ export const Clients = () => {
.then(() => {
toastSuccess(
<>
Deleted sip client <strong>{client.username}</strong>
</>,
Deleted outbound call route <strong>{client.username}</strong>
</>
);
setClient(null);
refetch();
@@ -81,48 +63,8 @@ export const Clients = () => {
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">
<H1 className="h2">Clients</H1>
<Link to={`${ROUTE_INTERNAL_CLIENTS}/add`} title="Add a client">
{" "}
<Icon>
<Icons.Plus />
@@ -130,7 +72,7 @@ export const Clients = () => {
</Link>
</section>
<section className="filters filters--multi">
<section className="filters filters--spaced">
<SearchFilter
placeholder="Filter clients"
filter={[filter, setFilter]}
@@ -183,7 +125,7 @@ export const Clients = () => {
<span>
{
accounts?.find(
(acct) => acct.account_sid === c.account_sid,
(acct) => acct.account_sid === c.account_sid
)?.name
}
</span>
@@ -210,13 +152,13 @@ export const Clients = () => {
</div>
))
) : (
<M>No sip clients.</M>
<M>No Clients.</M>
)}
</div>
</Section>
<Section clean>
<Button small as={Link} to={`${ROUTE_INTERNAL_CLIENTS}/add`}>
Add sip client
Add client
</Button>
</Section>
{client && (

View File

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

View File

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

View File

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

View File

@@ -2,35 +2,43 @@ 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 { useDispatch, useSelectState } from "src/store";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import {
toastError,
toastSuccess,
useDispatch,
useSelectState,
} from "src/store";
import { MSG_REQUIRED_FIELDS, NOT_AVAILABLE_PREFIX } from "src/constants";
import { setLocation } from "src/store/localStore";
import { AccountSelect, Message, Selector } from "src/components/forms";
import type {
Account,
Carrier,
Lcr,
LcrCarrierSetEntry,
LcrRoute,
UseApiDataMap,
} from "src/api/types";
import { ROUTE_INTERNAL_LEST_COST_ROUTING } from "src/router/routes";
import {
deleteLcr,
postLcrCarrierSetEntry,
putLcrCarrierSetEntries,
putLcrRoutes,
putLcr,
postLcrCreateRoutes,
putLcrUpdateRoutes,
useApiData,
useServiceProviderData,
getLcrRoute,
} from "src/api";
import { USER_ACCOUNT, USER_ADMIN } from "src/api/constants";
import { postLcr } from "src/api";
import { postLcrRoute } from "src/api";
import DeleteLcr from "./delete";
import { Scope } from "src/store/types";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import Container from "./container";
import { hasValue } from "src/utils";
import { useToast } from "src/components/toast/toast-provider";
import { v4 } from "uuid";
type LcrFormProps = {
lcrDataMap?: UseApiDataMap<Lcr>;
@@ -39,7 +47,7 @@ type LcrFormProps = {
export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
const LCR_ROUTE_TEMPLATE: LcrRoute = {
lcr_route_sid: "",
lcr_route_sid: `${NOT_AVAILABLE_PREFIX}${v4()}`,
regex: "",
lcr_sid: "",
priority: 0,
@@ -52,7 +60,6 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
],
};
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
const dispatch = useDispatch();
@@ -61,12 +68,15 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
const [defaultLcrCarrier, setDefaultLcrCarrier] = useState("");
const [defaultLcrCarrierSetEntrySid, setDefaultLcrCarrierSetEntrySid] =
useState<string | null>();
const [defaultLcrRoute, setDefaultLcrRoute] = useState<LcrRoute | null>(null);
const [defaultLcrRouteSid, setDefaultLcrRouteSid] = useState("");
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>();
@@ -79,7 +89,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
setLocation();
if (currentServiceProvider) {
setApiUrl(
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers${accountSid ? `?account_sid=${accountSid}` : ""}`,
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`
);
}
}, [user, currentServiceProvider, accountSid]);
@@ -89,8 +99,16 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
setAccountSid(user?.account_sid);
}
const ret = carriers
? carriers.map((c: Carrier, i) => {
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);
}
@@ -104,7 +122,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
setErrorMessage(
accountSid
? "There are no available carriers defined for this account"
: "There are no available carriers",
: "There are no available carriers"
);
} else {
setErrorMessage("");
@@ -112,61 +130,50 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
return ret;
}, [accountSid, carriers]);
useEffect(() => {
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data !== previouseLcr) {
setLcrName(lcrDataMap.data.name || "");
setIsActive(lcrDataMap.data.is_active);
setPreviousLcr(lcrDataMap.data);
if (lcrDataMap.data.account_sid) {
setAccountSid(lcrDataMap.data.account_sid);
}
}
}, [lcrDataMap?.data, previouseLcr]);
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data !== previouseLcr) {
setLcrName(lcrDataMap.data.name || "");
setIsActive(lcrDataMap.data.is_active);
setPreviousLcr(lcrDataMap.data);
}
if (
lcrRouteDataMap &&
lcrRouteDataMap.data &&
lcrRouteDataMap.data !== previousLcrRoutes
) {
setPreviousLcrRoutes(lcrRouteDataMap.data);
// Find default carrier
lcrRouteDataMap.data.forEach((lr) => {
lr.lcr_carrier_set_entries?.forEach((entry) => {
if (
entry.lcr_carrier_set_entry_sid ===
lcrDataMap?.data?.default_carrier_set_entry_sid
) {
setDefaultLcrCarrier(entry.voip_carrier_sid || defaultCarrier);
setDefaultLcrCarrierSetEntrySid(
entry.lcr_carrier_set_entry_sid || null
);
setDefaultLcrRouteSid(entry.lcr_route_sid || "");
}
});
});
}
useMemo(() => {
// Only process when both lcrDataMap and lcrRouteDataMap are available
if (lcrRouteDataMap && lcrRouteDataMap.data && lcrDataMap?.data) {
const defaultCarrierSetEntrySid =
lcrDataMap.data.default_carrier_set_entry_sid;
// Find and store default route information
lcrRouteDataMap.data.forEach((route) => {
route.lcr_carrier_set_entries?.forEach((entry) => {
if (entry.lcr_carrier_set_entry_sid === defaultCarrierSetEntrySid) {
setDefaultLcrCarrier(entry.voip_carrier_sid || defaultCarrier);
setDefaultLcrCarrierSetEntrySid(
entry.lcr_carrier_set_entry_sid || null,
);
setDefaultLcrRoute(route);
}
});
});
// Filter out routes that contain the default carrier set entry
const filteredRoutes = lcrRouteDataMap.data.filter((route) => {
return !route.lcr_carrier_set_entries?.some(
(entry) =>
entry.lcr_carrier_set_entry_sid === defaultCarrierSetEntrySid,
);
});
setLcrRoutes(filteredRoutes);
}
}, [lcrRouteDataMap?.data, lcrDataMap?.data]);
if (lcrRouteDataMap && lcrRouteDataMap.data)
setLcrRoutes(
lcrRouteDataMap.data.filter(
(route) => route.lcr_route_sid !== defaultLcrRouteSid
)
);
}, [defaultLcrRouteSid]);
const addLcrRoutes = () => {
const newLcrRoute = LCR_ROUTE_TEMPLATE;
const ls = [
...lcrRoutes,
{
...newLcrRoute,
...LCR_ROUTE_TEMPLATE,
priority: lcrRoutes.length,
lcr_carrier_set_entries: newLcrRoute.lcr_carrier_set_entries?.map(
(r) => ({
...r,
voip_carrier_sid: defaultCarrier || carrierSelectorOptions[0].value,
}),
),
},
];
setLcrRoutes(ls);
@@ -187,48 +194,11 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
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)
Promise.all(
lcrRoutes.map((route, i) => handleLcrRoutePost(json.sid, route, i))
)
.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" });
}
handleLcrDefaultCarrierPost(json.sid);
})
.catch(({ msg }) => {
toastError(msg);
@@ -239,38 +209,209 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
});
};
const handleLcrDefaultCarrierPost = (lcr_sid: string) => {
const defaultRoute = {
lcr_sid: lcr_sid,
regex: ".*",
desciption: "System Default Route",
priority: 9999,
lcr_carrier_set_entries: [
{
lcr_route_sid: "",
voip_carrier_sid: defaultLcrCarrier,
priority: 0,
},
],
};
handleLcrRoutePost(lcr_sid, defaultRoute, 9999).then((lcr_route_sid) => {
// There is small hack here to wait the data commited in bd for lcr entries
new Promise(async (r) => setTimeout(() => r(0), 300)).then(() => {
getLcrRoute(lcr_route_sid).then(({ json }) => {
if (json.lcr_carrier_set_entries?.length) {
const lcr_carrier_set_entry_sid =
json.lcr_carrier_set_entries[0].lcr_carrier_set_entry_sid;
putLcr(lcr_sid, {
default_carrier_set_entry_sid: lcr_carrier_set_entry_sid,
})
.then(() => {
if (lcrDataMap) {
toastSuccess("Least cost routing successfully updated");
} else {
toastSuccess("Least cost routing successfully created");
if (user?.access === Scope.admin) {
navigate(ROUTE_INTERNAL_LEST_COST_ROUTING);
} else {
navigate(
`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr_sid}/edit`
);
}
// Update global state
dispatch({ type: "lcr" });
}
})
.catch((error) => {
toastError(error);
});
}
});
});
});
};
const handleLcrRoutePost = (
lcr_sid: string,
route: LcrRoute,
priority: number
): Promise<string> => {
return new Promise(async (resolve, reject) => {
const lcrRoutePayload: LcrRoute = {
lcr_sid,
regex: route.regex,
priority,
};
postLcrRoute(lcrRoutePayload)
.then(({ json }) => {
if (route.lcr_carrier_set_entries) {
Promise.all(
route.lcr_carrier_set_entries.map((entry) => {
handleLcrCarrierSetEntryPost(json.sid, entry);
})
)
.then(() => {
resolve(json.sid);
})
.catch((error) => {
reject(error);
});
}
})
.catch((error) => {
reject(error);
});
});
};
const handleLcrCarrierSetEntryPost = (
lcr_route_sid: string,
entry: LcrCarrierSetEntry
): Promise<string> => {
const lcrCarrierSetEntryPayload: LcrCarrierSetEntry = {
...entry,
voip_carrier_sid: entry.voip_carrier_sid || defaultCarrier,
lcr_route_sid,
};
return new Promise<string>(async (r, e) => {
postLcrCarrierSetEntry(lcrCarrierSetEntryPayload)
.then(({ json }) => r(json.sid))
.catch((error) => e(error));
});
};
const handleLcrPut = () => {
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data.lcr_sid) {
// update LCR
const lcrPayload: Lcr = getLcrPayload();
putLcr(lcrDataMap.data.lcr_sid, lcrPayload).then(() => {
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,
})),
},
]
: []),
])
Promise.all(
lcrRoutes.map((route, i) => {
if (
route.lcr_route_sid &&
!route.lcr_route_sid?.startsWith(NOT_AVAILABLE_PREFIX)
) {
handleLcrRoutePut(
lcrDataMap.data?.lcr_sid || "",
route.lcr_route_sid,
route,
i
);
} else {
handleLcrRoutePost(lcrDataMap.data?.lcr_sid || "", route, i);
}
})
)
.then(() => {
toastSuccess("Least cost routing rule successfully updated");
if (defaultLcrCarrierSetEntrySid) {
const defaultEntry: LcrCarrierSetEntry = {
lcr_route_sid: defaultLcrRouteSid,
voip_carrier_sid: defaultLcrCarrier,
priority: 0,
};
handleLcrCarrierEntryPut(
defaultLcrRouteSid,
defaultLcrCarrierSetEntrySid,
defaultEntry
).then(() => {
toastSuccess("Least cost routing rule successfully updated");
});
}
})
.catch((error) => toastError(error));
});
}
const handleLcrRoutePut = (
lcr_sid: string,
lcr_route_sid: string,
route: LcrRoute,
priority: number
): Promise<string> => {
return new Promise(async (resolve, reject) => {
const lcrRoutePayload: LcrRoute = {
lcr_sid,
regex: route.regex,
priority,
};
putLcrRoutes(lcr_route_sid, lcrRoutePayload).then(() => {
if (
route.lcr_carrier_set_entries &&
route.lcr_carrier_set_entries.length > 0
) {
Promise.all(
route.lcr_carrier_set_entries.map((entry) => {
if (entry.lcr_carrier_set_entry_sid) {
return handleLcrCarrierEntryPut(
entry.lcr_route_sid || lcr_route_sid,
entry.lcr_carrier_set_entry_sid,
entry
);
} else {
return handleLcrCarrierSetEntryPost(lcr_route_sid, entry);
}
})
)
.then(() => {
resolve("Least cost routing rule successfully updated");
})
.catch((error) => {
reject(error);
});
}
});
});
};
const handleLcrCarrierEntryPut = (
lcr_route_sid: string,
lcr_carrier_set_entry_sid: string,
entry: LcrCarrierSetEntry
): Promise<string> => {
const lcrCarrierSetEntryPayload: LcrCarrierSetEntry = {
lcr_route_sid,
workload: entry.workload,
voip_carrier_sid: entry.voip_carrier_sid || defaultCarrier,
priority: entry.priority,
};
return new Promise<string>(async (r, e) => {
putLcrCarrierSetEntries(
lcr_carrier_set_entry_sid,
lcrCarrierSetEntryPayload
)
.then(() => r("success"))
.catch((error) => e(error));
});
};
};
const handleSubmit = (e: React.FormEvent) => {
@@ -289,7 +430,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
toastSuccess(
<>
Deleted least cost routing <strong>{lcrForDelete?.name}</strong>
</>,
</>
);
setLcrForDelete(null);
if (user?.access === Scope.admin) {
@@ -308,12 +449,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
return (
<>
<Section slim>
<form
className={`form form--internal ${
!lcrDataMap?.data && lcrDataMap?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<form className="form form--internal" onSubmit={handleSubmit}>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
{errorMessage && <Message message={errorMessage} />}

View File

@@ -14,7 +14,7 @@ import {
} from "src/components";
import { ScopedAccess } from "src/components/scoped-access";
import { ROUTE_INTERNAL_LEST_COST_ROUTING } from "src/router/routes";
import { useSelectState } from "src/store";
import { toastSuccess, toastError, useSelectState } from "src/store";
// import { getAccountFilter, setLocation } from "src/store/localStore";
import { Scope } from "src/store/types";
import {
@@ -25,16 +25,14 @@ import {
} from "src/utils";
import { USER_ACCOUNT } from "src/api/constants";
import DeleteLcr from "./delete";
import { useToast } from "src/components/toast/toast-provider";
export const Lcrs = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
useScopedRedirect(
Scope.admin,
`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`,
user,
"You do not have permissions to manage all outbound call routes",
"You do not have permissions to manage all outbound call routes"
);
const [lcrs, refetch] = useApiData<Lcr[]>("Lcrs");
const [filter, setFilter] = useState("");
@@ -55,9 +53,9 @@ export const Lcrs = () => {
accountSid
? lcr.account_sid === accountSid
: currentServiceProvider?.service_provider_sid
? lcr.service_provider_sid ==
currentServiceProvider.service_provider_sid
: lcr.account_sid === null,
? lcr.service_provider_sid ==
currentServiceProvider.service_provider_sid
: lcr.account_sid === null
)
: [];
}, [accountSid, lcrs]);
@@ -70,7 +68,7 @@ export const Lcrs = () => {
toastSuccess(
<>
Deleted outbound call route <strong>{lcr?.name}</strong>
</>,
</>
);
setLcr(null);
refetch();
@@ -101,7 +99,7 @@ export const Lcrs = () => {
multiple carriers available.
</M>
</section>
<section className="filters filters--multi">
<section className="filters filters--spaced">
<SearchFilter placeholder="Filter lcrs" filter={[filter, setFilter]} />
<ScopedAccess user={user} scope={Scope.admin}>
<AccountFilter
@@ -158,7 +156,7 @@ export const Lcrs = () => {
<span>
{lcr.account_sid
? accounts?.find(
(acct) => acct.account_sid === lcr.account_sid,
(acct) => acct.account_sid === lcr.account_sid
)?.name
: currentServiceProvider?.name}
</span>

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import {
ApplicationSelect,
} from "src/components/forms";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import { useSelectState } from "src/store";
import { toastError, toastSuccess, useSelectState } from "src/store";
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_MS_TEAMS_TENANTS,
@@ -28,7 +28,6 @@ import type {
MSTeamsTenant,
UseApiDataMap,
} from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
type MsTeamsTenantFormProps = {
msTeamsTenant?: UseApiDataMap<MSTeamsTenant>;
@@ -37,7 +36,6 @@ type MsTeamsTenantFormProps = {
export const MsTeamsTenantForm = ({
msTeamsTenant,
}: MsTeamsTenantFormProps) => {
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
@@ -51,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) => {
@@ -65,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;
@@ -122,12 +120,7 @@ export const MsTeamsTenantForm = ({
return (
<Section slim>
<form
className={`form form--internal ${
!msTeamsTenant?.data && msTeamsTenant?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<form className="form form--internal" onSubmit={handleSubmit}>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
</fieldset>
@@ -158,7 +151,7 @@ export const MsTeamsTenantForm = ({
applications={
applications
? applications.filter(
(application) => application.account_sid === accountSid,
(application) => application.account_sid === accountSid
)
: []
}

View File

@@ -13,6 +13,7 @@ import {
withAccessControl,
useFilteredResults,
} from "src/utils";
import { toastError, toastSuccess } from "src/store";
import {
Icons,
Section,
@@ -28,15 +29,13 @@ import { DeleteMsTeamsTenant } from "./delete";
import type { Account, MSTeamsTenant, Application } from "src/api/types";
import type { ACLGetIMessage } from "src/utils/with-access-control";
import { useToast } from "src/components/toast/toast-provider";
export const MSTeamsTenants = () => {
const { toastSuccess, toastError } = useToast();
const [msTeamsTenant, setMsTeamsTenant] = useState<MSTeamsTenant | null>(
null,
null
);
const [msTeamsTenants, refetch] = useApiData<MSTeamsTenant[]>(
"MicrosoftTeamsTenants",
"MicrosoftTeamsTenants"
);
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useServiceProviderData<Application[]>("Applications");
@@ -46,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 = () => {
@@ -66,7 +65,7 @@ export const MSTeamsTenants = () => {
<>
Deleted Microsoft Teams Tenant{" "}
<strong>{msTeamsTenant.tenant_fqdn}</strong>
</>,
</>
);
})
.catch((error) => {
@@ -90,7 +89,7 @@ export const MSTeamsTenants = () => {
</Link>
)}
</section>
<section className="filters filters--multi">
<section className="filters filters--spaced">
<SearchFilter
placeholder="Filter ms teams tenants"
filter={[filter, setFilter]}
@@ -132,8 +131,7 @@ export const MSTeamsTenants = () => {
{
accounts?.find(
(acct) =>
acct.account_sid ===
msTeamsTenant.account_sid,
acct.account_sid === msTeamsTenant.account_sid
)?.name
}
</span>
@@ -150,7 +148,7 @@ export const MSTeamsTenants = () => {
{applications?.find(
(app) =>
app.application_sid ===
msTeamsTenant.application_sid,
msTeamsTenant.application_sid
)?.name || "None"}
</span>
</div>
@@ -219,5 +217,5 @@ const getAclIMessage: ACLGetIMessage = (currentServiceProvider) => {
export default withAccessControl(
"hasMSTeamsFqdn",
getAclIMessage,
getAclIMessage
)(MSTeamsTenants);

View File

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

View File

@@ -10,9 +10,9 @@ import {
import { Section } from "src/components";
import {
Message,
Selector,
AccountSelect,
ApplicationSelect,
TypeaheadSelector,
} from "src/components/forms";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import {
@@ -20,6 +20,7 @@ import {
ROUTE_INTERNAL_CARRIERS,
ROUTE_INTERNAL_PHONE_NUMBERS,
} from "src/router/routes";
import { toastError, toastSuccess } from "src/store";
import { hasLength, useRedirect } from "src/utils";
import type {
@@ -30,36 +31,33 @@ import type {
UseApiDataMap,
} from "src/api/types";
import { setAccountFilter, setLocation } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
type PhoneNumberFormProps = {
phoneNumber?: UseApiDataMap<PhoneNumber>;
};
export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useServiceProviderData<Application[]>("Applications");
const [phoneNumbers] = useServiceProviderData<PhoneNumber[]>("PhoneNumbers");
const [voipCarriers] = useServiceProviderData<Carrier[]>("VoipCarriers");
const [carriers] = 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>(
voipCarriers,
carriers,
ROUTE_INTERNAL_CARRIERS,
"You must create a SIP trunk before you can create a phone number.",
"You must create a SIP trunk before you can create a phone number."
);
const handleSubmit = (e: React.FormEvent) => {
@@ -71,7 +69,7 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
const filtered =
phoneNumber && phoneNumber.data
? phoneNumbers.filter(
(a) => a.phone_number_sid !== phoneNumber.data!.phone_number_sid,
(a) => a.phone_number_sid !== phoneNumber.data!.phone_number_sid
)
: phoneNumbers;
@@ -92,7 +90,7 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
phoneNumber.refetch();
toastSuccess("Phone number updated successfully");
navigate(
`${ROUTE_INTERNAL_PHONE_NUMBERS}/${phoneNumber.data?.phone_number_sid}/edit`,
`${ROUTE_INTERNAL_PHONE_NUMBERS}/${phoneNumber.data?.phone_number_sid}/edit`
);
})
.catch((error) => {
@@ -140,29 +138,10 @@ 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 ${
!phoneNumber?.data && phoneNumber?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<form className="form form--internal" onSubmit={handleSubmit}>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
</fieldset>
@@ -181,17 +160,11 @@ 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>
<TypeaheadSelector
<Selector
id="sip_trunk"
name="sip_trunk"
required
@@ -210,18 +183,21 @@ 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,
)
.sort((a, b) => a.name.localeCompare(b.name))
? applications.filter(
(application) => application.account_sid === accountSid
)
: []
}
/>

View File

@@ -1,14 +1,13 @@
import React, { useEffect, useState, useRef } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { Button, ButtonGroup, H1, Icon, MS } from "@jambonz/ui-kit";
import { Link } from "react-router-dom";
import {
deletePhoneNumber,
getPhoneNumbers,
putPhoneNumber,
useServiceProviderData,
} from "src/api";
import { useSelectState } from "src/store";
import { toastError, toastSuccess, useSelectState } from "src/store";
import {
Icons,
Section,
@@ -16,39 +15,34 @@ import {
ApplicationFilter,
SearchFilter,
AccountFilter,
Pagination,
SelectFilter,
} from "src/components";
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_CARRIERS,
ROUTE_INTERNAL_PHONE_NUMBERS,
} from "src/router/routes";
import { hasLength, hasValue, formatPhoneNumber } from "src/utils";
import {
hasLength,
hasValue,
formatPhoneNumber,
useFilteredResults,
} from "src/utils";
import { DeletePhoneNumber } from "./delete";
import type { Account, PhoneNumber, Carrier, Application } from "src/api/types";
import {
PER_PAGE_SELECTION,
USER_ACCOUNT,
USER_ADMIN,
ADMIN_CARRIER,
USER_SP,
} from "src/api/constants";
import { USER_ACCOUNT } from "src/api/constants";
import { ScopedAccess } from "src/components/scoped-access";
import { Scope } from "src/store/types";
import { getAccountFilter, setLocation } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
export const PhoneNumbers = () => {
const { toastSuccess, toastError } = useToast();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useServiceProviderData<Application[]>("Applications");
const [carriers] = useServiceProviderData<Carrier[]>("VoipCarriers");
const [phoneNumber, setPhoneNumber] = useState<PhoneNumber | null>(null);
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumber[] | null>(null);
const [phoneNumbers, refetch] =
useServiceProviderData<PhoneNumber[]>("PhoneNumbers");
const [selectedPhoneNumbers, setSelectedPhoneNumbers] = useState<
PhoneNumber[]
>([]);
@@ -57,54 +51,20 @@ export const PhoneNumbers = () => {
const [applyMassEdit, setApplyMassEdit] = useState(false);
const [filter, setFilter] = useState("");
const [accountSid, setAccountSid] = useState("");
const [phoneNumbersTotal, setphoneNumbersTotal] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [perPageFilter, setPerPageFilter] = useState("25");
const [maxPageNumber, setMaxPageNumber] = useState(1);
// Add ref to track previous values
const prevValuesRef = useRef({
serviceProviderId: "",
accountSid: "",
filter: "",
pageNumber: 1,
perPageFilter: "25",
});
const phoneNumbersFiltered = useMemo(() => {
setAccountSid(getAccountFilter());
return phoneNumbers
? phoneNumbers.filter(
(phn) => !accountSid || phn.account_sid === accountSid
)
: [];
}, [accountSid, phoneNumbers]);
const fetchPhoneNumbers = (resetPage = false) => {
setPhoneNumbers(null);
// Calculate the correct page to use
const currentPage = resetPage ? 1 : pageNumber;
// If we're resetting the page, also update the state
if (resetPage && pageNumber !== 1) {
setPageNumber(1);
}
const accSid = accountSid || getAccountFilter() || "";
getPhoneNumbers({
page: currentPage,
page_size: Number(perPageFilter),
...(accSid && { account_sid: accSid }),
...(filter && { filter }),
...(currentServiceProvider?.service_provider_sid && {
service_provider_sid: currentServiceProvider.service_provider_sid,
}),
})
.then(({ json }) => {
if (json) {
setPhoneNumbers(json.data);
setphoneNumbersTotal(json.total);
setMaxPageNumber(Math.ceil(json.total / Number(perPageFilter)));
}
})
.catch((error) => {
setPhoneNumbers([]);
toastError(error.msg);
});
};
const filteredPhoneNumbers = useFilteredResults<PhoneNumber>(
filter,
phoneNumbersFiltered
);
const handleMassEdit = () => {
Promise.all(
@@ -114,14 +74,12 @@ export const PhoneNumbers = () => {
};
return putPhoneNumber(phoneNumber.phone_number_sid, payload);
}),
})
)
.then(() => {
fetchPhoneNumbers(false);
refetch();
setApplicationSid("");
setApplyMassEdit(false);
setSelectAll(false);
setSelectedPhoneNumbers([]);
toastSuccess("Number routing updated successfully");
})
.catch((error) => {
@@ -135,12 +93,12 @@ export const PhoneNumbers = () => {
if (phoneNumber) {
deletePhoneNumber(phoneNumber.phone_number_sid)
.then(() => {
fetchPhoneNumbers(false);
refetch();
setPhoneNumber(null);
toastSuccess(
<>
Deleted phone number <strong>{phoneNumber.number}</strong>
</>,
</>
);
})
.catch((error) => {
@@ -149,68 +107,32 @@ export const PhoneNumbers = () => {
}
};
// Initial account setup
useEffect(() => {
setLocation();
if (user?.account_sid && user.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
} else {
setAccountSid(getAccountFilter() || accountSid);
}
setLocation();
}, [user]);
// Combined effect for all data fetching
useEffect(() => {
const prevValues = prevValuesRef.current;
const currentSPId = currentServiceProvider?.service_provider_sid;
// Detect changes that require page reset
const isFilterOrProviderChange =
prevValues.serviceProviderId !== currentSPId ||
prevValues.accountSid !== accountSid ||
prevValues.filter !== filter;
const isPageSizeChange =
prevValues.perPageFilter !== perPageFilter &&
prevValues.perPageFilter !== "25"; // Skip initial render
// Update ref for next comparison
prevValuesRef.current = {
serviceProviderId: currentSPId || "",
accountSid,
filter,
pageNumber,
perPageFilter,
};
// Fetch data with appropriate reset parameter
fetchPhoneNumbers(isFilterOrProviderChange || isPageSizeChange);
}, [currentServiceProvider, accountSid, filter, pageNumber, perPageFilter]);
return (
<>
<section className="mast">
<H1 className="h2">Phone numbers</H1>
{hasLength(accounts) &&
hasLength(carriers) &&
((ADMIN_CARRIER === "1" &&
(user?.scope === USER_ADMIN || user?.scope === USER_SP)) ||
ADMIN_CARRIER === "0") && (
<Link
to={`${ROUTE_INTERNAL_PHONE_NUMBERS}/add`}
title="Add a phone number"
>
<Icon>
<Icons.Plus />
</Icon>
</Link>
)}
{hasLength(accounts) && hasLength(carriers) && (
<Link
to={`${ROUTE_INTERNAL_PHONE_NUMBERS}/add`}
title="Add a phone number"
>
<Icon>
<Icons.Plus />
</Icon>
</Link>
)}
</section>
<section className="filters filters--multi">
<section className="filters filters--spaced">
<SearchFilter
placeholder="Filter phone numbers"
filter={[filter, setFilter]}
delay={1000}
/>
<ScopedAccess user={user} scope={Scope.service_provider}>
<AccountFilter
@@ -220,11 +142,11 @@ export const PhoneNumbers = () => {
/>
</ScopedAccess>
</section>
<Section {...(hasLength(phoneNumbers) && { slim: true })}>
<Section {...(hasLength(filteredPhoneNumbers) && { slim: true })}>
<div className="list">
{!hasValue(phoneNumbers) ? (
<Spinner />
) : hasLength(phoneNumbers) ? (
) : hasLength(filteredPhoneNumbers) ? (
<>
<div className="item item--actions">
{accountSid ? (
@@ -238,7 +160,7 @@ export const PhoneNumbers = () => {
onChange={(e) => {
if (e.target.checked) {
setSelectAll(true);
setSelectedPhoneNumbers(phoneNumbers);
setSelectedPhoneNumbers(filteredPhoneNumbers);
} else {
setSelectAll(false);
setSelectedPhoneNumbers([]);
@@ -255,15 +177,17 @@ export const PhoneNumbers = () => {
application={[applicationSid, setApplicationSid]}
applications={applications?.filter(
(application) =>
application.account_sid === accountSid,
application.account_sid === accountSid
)}
defaultOption="None"
/>
<Button
small
onClick={() => {
setApplyMassEdit(true);
handleMassEdit();
setSelectAll(false);
setApplyMassEdit(true);
setSelectedPhoneNumbers([]);
}}
>
Apply
@@ -285,7 +209,7 @@ export const PhoneNumbers = () => {
</MS>
)}
</div>
{phoneNumbers.map((phoneNumber) => {
{filteredPhoneNumbers.map((phoneNumber) => {
return (
<div className="item" key={phoneNumber.phone_number_sid}>
<div className="item__info">
@@ -300,7 +224,7 @@ export const PhoneNumbers = () => {
selectedPhoneNumbers.find(
(phone) =>
phone.phone_number_sid ===
phoneNumber.phone_number_sid,
phoneNumber.phone_number_sid
)
? true
: false
@@ -316,8 +240,8 @@ export const PhoneNumbers = () => {
curr.filter(
(phone) =>
phone.phone_number_sid !==
phoneNumber.phone_number_sid,
),
phoneNumber.phone_number_sid
)
);
}
}}
@@ -346,8 +270,7 @@ export const PhoneNumbers = () => {
{
accounts?.find(
(acct) =>
acct.account_sid ===
phoneNumber.account_sid,
acct.account_sid === phoneNumber.account_sid
)?.name
}
</span>
@@ -364,7 +287,7 @@ export const PhoneNumbers = () => {
{applications?.find(
(app) =>
app.application_sid ===
phoneNumber.application_sid,
phoneNumber.application_sid
)?.name || "None"}
</span>
</div>
@@ -378,19 +301,14 @@ export const PhoneNumbers = () => {
>
<Icons.Edit3 />
</Link>
{((ADMIN_CARRIER === "1" &&
(user?.scope === USER_ADMIN ||
user?.scope === USER_SP)) ||
ADMIN_CARRIER === "0") && (
<button
type="button"
title="Delete phone number"
onClick={() => setPhoneNumber(phoneNumber)}
className="btnty"
>
<Icons.Trash />
</button>
)}
<button
type="button"
title="Delete phone number"
onClick={() => setPhoneNumber(phoneNumber)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
</div>
);
@@ -419,37 +337,13 @@ export const PhoneNumbers = () => {
)}
</div>
</Section>
{((ADMIN_CARRIER === "1" &&
(user?.scope === USER_ADMIN || user?.scope === USER_SP)) ||
ADMIN_CARRIER === "0") && (
<Section clean>
{hasLength(accounts) && hasLength(carriers) && (
<Button small as={Link} to={`${ROUTE_INTERNAL_PHONE_NUMBERS}/add`}>
Add phone number
</Button>
)}
</Section>
)}
<footer>
<ButtonGroup>
<MS>
Total: {phoneNumbersTotal} record
{phoneNumbersTotal === 1 ? "" : "s"}
</MS>
{hasLength(phoneNumbers) && (
<Pagination
pageNumber={pageNumber}
setPageNumber={setPageNumber}
maxPageNumber={maxPageNumber}
/>
)}
<SelectFilter
id="page_filter"
filter={[perPageFilter, setPerPageFilter]}
options={PER_PAGE_SELECTION}
/>
</ButtonGroup>
</footer>
<Section clean>
{hasLength(accounts) && hasLength(carriers) && (
<Button small as={Link} to={`${ROUTE_INTERNAL_PHONE_NUMBERS}/add`}>
Add phone number
</Button>
)}
</Section>
{phoneNumber && (
<DeletePhoneNumber
phoneNumber={phoneNumber}

View File

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

View File

@@ -98,7 +98,7 @@ export const CallTracing = ({ call }: CallTracingProps) => {
rootGroup.children = buildChildren(
rootGroup.level + 1,
rootGroup,
groups,
groups
);
setJaegerGroup(rootGroup);
}
@@ -108,7 +108,7 @@ export const CallTracing = ({ call }: CallTracingProps) => {
const buildChildren = (
level: number,
rootGroup: JaegerGroup,
groups: JaegerGroup[],
groups: JaegerGroup[]
): JaegerGroup[] => {
return getGroupsByParent(rootGroup.spanId, groups).map((group) => {
group.level = level;

View File

@@ -8,10 +8,9 @@ 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 { DISABLE_JAEGER_TRACING } from "src/api/constants";
import { Player } from "./player";
import "./styles.scss";
import CallSystemLogs from "./call-system-logs";
type DetailsItemProps = {
call: RecentCall;
@@ -77,15 +76,8 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
<CallDetail call={transformRecentCall(call)} />
</Tab>
<Tab id="tracing" label="Tracing">
{open && <CallTracing call={call} />}
<CallTracing call={call} />
</Tab>
{hasValue(AWS_REGION) ? (
<Tab id="logs" label="Logs">
{open && <CallSystemLogs call={call} />}
</Tab>
) : (
<></>
)}
</Tabs>
)}
{open && (

View File

@@ -8,7 +8,7 @@ import {
PER_PAGE_SELECTION,
USER_ACCOUNT,
} from "src/api/constants";
import { useSelectState } from "src/store";
import { toastError, useSelectState } from "src/store";
import {
Section,
AccountFilter,
@@ -28,7 +28,6 @@ import {
getQueryFilter,
setLocation,
} from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
const directionSelection = [
{ name: "either", value: "io" },
@@ -43,14 +42,14 @@ const statusSelection = [
];
export const RecentCalls = () => {
const { toastError } = useToast();
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
const [dateFilter, setDateFilter] = useState("today");
const [directionFilter, setDirectionFilter] = useState("io");
const [statusFilter, setStatusFilter] = useState("all");
const [filter, setFilter] = useState("");
const [fromFilter, setFromFilter] = useState("");
const [toFilter, setToFilter] = useState("");
const [pageNumber, setPageNumber] = useState(1);
const [perPageFilter, setPerPageFilter] = useState("25");
@@ -65,15 +64,11 @@ export const RecentCalls = () => {
count: Number(perPageFilter),
...(dateFilter === "today"
? { start: dayjs().startOf("date").toISOString() }
: dateFilter === "yesterday"
? {
start: dayjs().subtract(1, "day").startOf("day").toISOString(),
end: dayjs().subtract(1, "day").endOf("day").toISOString(),
}
: { days: Number(dateFilter) }),
: { days: Number(dateFilter) }),
...(statusFilter !== "all" && { answered: statusFilter }),
...(directionFilter !== "io" && { direction: directionFilter }),
...(filter && { filter }),
...(fromFilter && { from: fromFilter }),
...(toFilter && { to: toFilter }),
};
getRecentCalls(accountSid, payload)
@@ -89,10 +84,10 @@ export const RecentCalls = () => {
};
useMemo(() => {
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
if (getQueryFilter()) {
const [date, direction, status] = getQueryFilter().split("/");
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
setDateFilter(date);
setDirectionFilter(direction);
setStatusFilter(status);
@@ -110,7 +105,8 @@ export const RecentCalls = () => {
dateFilter,
directionFilter,
statusFilter,
filter,
fromFilter,
toFilter,
]);
/** Reset page number when filters change */
@@ -154,8 +150,13 @@ export const RecentCalls = () => {
options={statusSelection}
/>
<SearchFilter
placeholder="Filter"
filter={[filter, setFilter]}
placeholder="Filter From"
filter={[fromFilter, setFromFilter]}
delay={1000}
/>
<SearchFilter
placeholder="Filter To"
filter={[toFilter, setToFilter]}
delay={1000}
/>
</section>
@@ -164,10 +165,7 @@ export const RecentCalls = () => {
{!hasValue(calls) && hasLength(accounts) ? (
<Spinner />
) : hasLength(calls) ? (
//call.call_sid is null incase of failure, cannot be used as key
calls.map((call) => (
<DetailsItem key={call.sip_callid} call={call} />
))
calls.map((call) => <DetailsItem key={call.call_sid} call={call} />)
) : (
<M>No data.</M>
)}

View File

@@ -3,7 +3,6 @@ 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;
@@ -66,37 +65,6 @@ export const JaegerDetail = ({ group }: JaegerDetailProps) => {
</div>
</div>
))}
{/* TTS Streaming Attrs */}
{group.children.length &&
getSpansByNameRegex(group.children, /tts-generation/)?.map((span) => {
return span.attributes.map((attribute) => {
if (
![
"tts.vendor",
"tts.language",
"tts.voice",
"tts.cached",
"engine",
"voice",
].includes(attribute.key)
) {
return (
<div
key={attribute.key}
className="spanDetailsWrapper__details"
>
<div className="spanDetailsWrapper__details_header">
<strong>{attribute.key}</strong>:
</div>
<div className="spanDetailsWrapper__details_body">
{extractSpanGroupValue(attribute.value)}
</div>
</div>
);
}
});
})}
</div>
</div>
);

View File

@@ -4,6 +4,7 @@
.barGroup {
border-radius: ui-vars.$px01;
@include mixins.code();
text-align: left;
padding: ui-vars.$px03;
color: ui-vars.$pink;
@@ -12,7 +13,6 @@
margin-top: ui-vars.$px02;
overflow-x: auto;
overflow-y: scroll;
@include mixins.code();
@media (max-width: 600px) {
padding: 15px;
@@ -72,6 +72,7 @@
.spanDetailsWrapper {
border-radius: ui-vars.$px01;
@include mixins.code();
text-align: left;
padding: ui-vars.$px01;
background-color: ui-vars.$white;
@@ -81,7 +82,6 @@
max-width: ui-vars.$width-tablet-2;
max-height: 500px;
overflow-y: scroll;
@include mixins.code();
&__detailsWrapper {
height: 100%;

View File

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

View File

@@ -3,33 +3,30 @@ import React from "react";
import WaveSurfer from "wavesurfer.js";
import { useEffect, useRef, useState } from "react";
import { Icon, P } from "@jambonz/ui-kit";
import { Icons, Modal, ModalClose } from "src/components";
import { deleteRecord, getBlob, getJaegerTrace } from "src/api";
import { Icons, ModalClose } from "src/components";
import { getBlob, getJaegerTrace } from "src/api";
import { DownloadedBlob, RecentCall } from "src/api/types";
import RegionsPlugin, { Region } from "wavesurfer.js/dist/plugins/regions";
import TimelinePlugin from "wavesurfer.js/dist/plugins/timeline";
import RegionsPlugin, { Region } from "wavesurfer.js/src/plugin/regions";
import TimelinePlugin from "wavesurfer.js/src/plugin/timeline";
import { API_BASE_URL } from "src/api/constants";
import {
JaegerRoot,
JaegerSpan,
WaveSurferDtmfResult,
WaveSurferGatherSpeechVerbHookLatencyResult,
WaveSurferSttResult,
WaveSurferTtsLatencyResult,
WaveSufferDtmfResult,
WaveSufferSttResult,
} from "src/api/jaeger-types";
import {
getSpanAttributeByName,
getSpansByName,
getSpansByNameRegex,
getSpansFromJaegerRoot,
} from "./utils";
import { useToast } from "src/components/toast/toast-provider";
type PlayerProps = {
call: RecentCall;
};
export const Player = ({ call }: PlayerProps) => {
const { toastSuccess, toastError } = useToast();
const { recording_url, call_sid } = call;
const url =
recording_url && recording_url.startsWith("http://")
@@ -40,39 +37,26 @@ export const Player = ({ call }: PlayerProps) => {
const [isReady, setIsReady] = useState(false);
const [playBackTime, setPlayBackTime] = useState("");
const [jaegerRoot, setJeagerRoot] = useState<JaegerRoot>();
const [waveSurferRegionData, setWaveSurferRegionData] =
useState<WaveSurferSttResult | null>();
const [waveSurferDtmfData, setWaveSurferDtmfData] =
useState<WaveSurferDtmfResult | null>();
const [waveSurferTtsLatencyData, setWaveSurferTtsLatencyData] =
useState<WaveSurferTtsLatencyResult | null>();
const [
waveSurferGatherSpeechVerbHookLatencyData,
setWaveSurferGatherSpeechVerbHookLatencyData,
] = useState<WaveSurferGatherSpeechVerbHookLatencyResult | null>();
const [waveSufferRegionData, setWaveSufferRegionData] =
useState<WaveSufferSttResult | null>();
const [waveSufferDtmfData, setWaveSufferDtmfData] =
useState<WaveSufferDtmfResult | null>();
const [regionChecked, setRegionChecked] = useState(false);
const wavesurferId = `wavesurfer--${call_sid}`;
const waveSurferRef = useRef<WaveSurfer | null>(null);
const waveSurferRegionsPluginRef = useRef<RegionsPlugin | null>();
const wavesurferTimelineId = `timeline-${wavesurferId}`;
const waveSufferRef = useRef<WaveSurfer | null>(null);
const [record, setRecord] = useState<DownloadedBlob | null>(null);
const [deleteRecordUrl, setDeleteRecordUrl] = useState("");
const drawDtmfRegionForSpan = (s: JaegerSpan, startPoint: JaegerSpan) => {
if (waveSurferRegionsPluginRef.current) {
waveSurferRef.current;
const r = waveSurferRegionsPluginRef.current
.getRegions()
.find((r) => r.id === s.spanId);
if (waveSufferRef.current) {
const r = waveSufferRef.current.regions.list[s.spanId];
if (!r) {
const [dtmfValue] = getSpanAttributeByName(s.attributes, "dtmf");
const [durationValue] = getSpanAttributeByName(
s.attributes,
"duration",
"duration"
);
if (dtmfValue && durationValue) {
const start =
@@ -80,28 +64,29 @@ export const Player = ({ call }: PlayerProps) => {
1_000_000_000;
const duration =
Number(durationValue.value.stringValue.replace("ms", "")) / 1_000;
// as duration of DTMF is short, cannot be shown in wavesurfer,
// as duration of DTMF is short, cannot be shown in wavesuffer,
// adjust region width here.
const delta = duration <= 0.1 ? 0.1 : duration;
const end = start + delta;
const region = waveSurferRegionsPluginRef.current.addRegion({
const region = waveSufferRef.current.addRegion({
id: s.spanId,
start,
end,
color: "rgba(138, 43, 226, 0.15)",
drag: false,
loop: false,
resize: false,
});
changeRegionMouseStyle(region);
const att: WaveSurferDtmfResult = {
const att: WaveSufferDtmfResult = {
dtmf: dtmfValue.value.stringValue,
duration: durationValue.value.stringValue,
};
region.on("click", () => {
setWaveSurferDtmfData(att);
setWaveSufferDtmfData(att);
});
}
}
@@ -122,53 +107,13 @@ export const Player = ({ call }: PlayerProps) => {
});
};
const PEAKS_WINDOW = 5; // require 30 ms of speech energy over threshold to trigger
const PEAK_THRESHOLD = 0.03;
const getSilenceStartTime = (
start: number,
end: number,
channel: number,
): number => {
if (waveSurferRef.current) {
const duration = waveSurferRef.current.getDecodedData()?.duration;
if (duration && duration > 0) {
const maxLength = Math.round(duration * 8000) / 10; // evaluate speech energy every 10 ms
const peaks = waveSurferRef.current.exportPeaks({ maxLength });
if (peaks && peaks.length > channel) {
if (duration && duration > 0) {
const data = peaks[channel];
const startPeak = Math.ceil((start * data.length) / duration);
const endPeak = Math.ceil((end * data.length) / duration);
let count = 0;
for (let i = endPeak; i > startPeak; i--)
if (Math.abs(data[i]) > PEAK_THRESHOLD) {
count++;
if (count === PEAKS_WINDOW) {
return (
((i + PEAKS_WINDOW) * duration) / data.length + 0.02 // add 20 ms adjustment
);
}
} else {
count = 0;
}
}
}
}
}
return -1;
};
const drawSttRegionForSpan = (
s: JaegerSpan,
allSpans: JaegerSpan[],
startPoint: JaegerSpan,
channel = 0,
channel = 0
) => {
if (waveSurferRegionsPluginRef.current) {
const r = waveSurferRegionsPluginRef.current
.getRegions()
.find((r) => r.id === s.spanId);
if (waveSufferRef.current) {
const r = waveSufferRef.current.regions.list[s.spanId];
if (!r) {
const start =
(s.startTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000 +
@@ -176,75 +121,30 @@ export const Player = ({ call }: PlayerProps) => {
const end =
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
const verbHookSpans = getSpansByNameRegex(allSpans, /verb:hook/);
const verbHookSpan = verbHookSpans.find(
(v) => v.parentSpanId === s.spanId,
);
let verbHookDurantion = 0;
let latency = 0;
if (verbHookSpan) {
verbHookDurantion =
(verbHookSpan.endTimeUnixNano - verbHookSpan.startTimeUnixNano) /
1_000_000_000;
}
const [sttLatencyMs] = getSpanAttributeByName(
s.attributes,
"stt.latency_ms",
);
let endSpeechTime = 0;
if (!sttLatencyMs) {
endSpeechTime = getSilenceStartTime(start, end, channel);
latency = Number(
(end - endSpeechTime - verbHookDurantion).toFixed(2),
);
} else {
endSpeechTime =
end -
Number(sttLatencyMs.value.stringValue) / 1_000 -
verbHookDurantion;
latency = Number(sttLatencyMs.value.stringValue) / 1_000;
}
const region = waveSufferRef.current.addRegion({
id: s.spanId,
start,
end,
color: "rgba(255, 0, 0, 0.15)",
drag: false,
loop: false,
resize: false,
});
changeRegionMouseStyle(region, channel);
const [sttResult] = getSpanAttributeByName(s.attributes, "stt.result");
let att: WaveSurferSttResult;
let att: WaveSufferSttResult;
if (sttResult) {
const data = JSON.parse(sttResult.value.stringValue);
att = {
vendor: data.vendor.name,
transcript: data.alternatives[0].transcript,
confidence: data.alternatives[0].confidence,
language_code: data.language_code,
latency,
};
const [sttResolve] = getSpanAttributeByName(
s.attributes,
"stt.resolve",
);
if (
endSpeechTime > 0 &&
sttResolve &&
sttResolve.value.stringValue === "speech"
) {
const latencyRegion = waveSurferRegionsPluginRef.current.addRegion({
id: s.spanId + "latency",
start: endSpeechTime,
end,
color: "rgba(255, 255, 0, 0.55)",
drag: false,
resize: false,
content: `${latency}s`,
});
changeRegionMouseStyle(latencyRegion, channel);
}
} else {
const [sttResolve] = getSpanAttributeByName(
s.attributes,
"stt.resolve",
"stt.resolve"
);
if (sttResolve && sttResolve.value.stringValue === "timeout") {
att = {
@@ -264,188 +164,45 @@ export const Player = ({ call }: PlayerProps) => {
}
}
const region = waveSurferRegionsPluginRef.current.addRegion({
id: s.spanId,
start,
end,
color: "rgba(255, 0, 0, 0.15)",
drag: false,
resize: false,
});
changeRegionMouseStyle(region, channel);
region.on("click", () => {
setWaveSurferRegionData(att);
setWaveSufferRegionData(att);
});
}
}
};
const drawTtsLatencyRegion = (s: JaegerSpan, startPoint: JaegerSpan) => {
if (waveSurferRegionsPluginRef.current) {
const r = waveSurferRegionsPluginRef.current
.getRegions()
.find((r) => r.id === s.spanId);
if (!r) {
const start =
(s.startTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
let end =
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
const [ttsVendor] = getSpanAttributeByName(s.attributes, "tts.vendor");
const [ttsCache] = getSpanAttributeByName(s.attributes, "tts.cached");
const [streamLatency] = getSpanAttributeByName(
s.attributes,
"time_to_first_byte_ms",
);
if (streamLatency && streamLatency.value.stringValue) {
end = start + Number(streamLatency.value.stringValue) / 1_000;
}
if (ttsVendor && ttsCache && !Boolean(ttsCache.value.boolValue)) {
const latencyRegion = waveSurferRegionsPluginRef.current.addRegion({
id: s.spanId,
start: start,
end,
color: "rgba(255, 155, 0, 0.55)",
drag: false,
resize: false,
content: createMultiLineTextElement(`${(end - start).toFixed(2)}s`),
});
changeRegionMouseStyle(latencyRegion, 1);
latencyRegion.on("click", () => {
setWaveSurferTtsLatencyData({
vendor: ttsVendor.value.stringValue,
latency: `${(end - start).toFixed(2)}s`,
isCached: String(ttsCache.value.boolValue),
});
});
}
}
}
};
const drawVerbHookDelayRegion = (s: JaegerSpan, startPoint: JaegerSpan) => {
if (waveSurferRegionsPluginRef.current) {
const r = waveSurferRegionsPluginRef.current
.getRegions()
.find((r) => r.id === s.spanId);
if (!r) {
const start =
(s.startTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
const end =
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
const tmpEnd = end - start < 0.05 ? start + 0.05 : end;
const latencyRegion = waveSurferRegionsPluginRef.current.addRegion({
id: s.spanId,
start: start,
end: tmpEnd,
color: "rgba(255, 3, 180, 0.55)",
drag: false,
resize: false,
content: createMultiLineTextElement(`${(end - start).toFixed(2)}s`),
});
const [statusCode] = getSpanAttributeByName(
s.attributes,
"http.statusCode",
);
changeRegionMouseStyle(latencyRegion, 0);
latencyRegion.on("click", () => {
setWaveSurferGatherSpeechVerbHookLatencyData({
statusCode: statusCode ? Number(statusCode.value.doubleValue) : 404,
latency: `${(end - start).toFixed(2)}s`,
});
});
}
}
};
function createMultiLineTextElement(text: string) {
const div = document.createElement("div");
div.style.paddingLeft = "10px";
div.style.paddingTop = "15px";
div.appendChild(document.createElement("br"));
div.appendChild(document.createTextNode(text));
return div;
}
const buildWavesurferRegion = () => {
const buildWavesufferRegion = () => {
if (jaegerRoot) {
const spans = getSpansFromJaegerRoot(jaegerRoot);
const start = getSpansByNameRegex(spans, /background-record:listen/);
const startPoint = start ? start[0] : null;
const [startPoint] = getSpansByName(spans, "background-listen:listen");
// there should be only one startPoint for background listen
if (startPoint) {
const gatherSpans = getSpansByNameRegex(spans, /:gather{/);
gatherSpans.forEach((s) => {
drawSttRegionForSpan(s, spans, startPoint);
drawSttRegionForSpan(s, startPoint);
});
// Trasscription
const transcribeSpans = getSpansByNameRegex(spans, /stt-listen:/);
transcribeSpans.forEach((cs) => {
// Channel start from 0
const channel = Number(cs.name.split(":")[1]);
drawSttRegionForSpan(
cs,
spans,
startPoint,
channel > 0 ? channel - 1 : channel,
channel > 0 ? channel - 1 : channel
);
});
// DTMF
const dtmfSpans = getSpansByNameRegex(spans, /dtmf:/);
dtmfSpans.forEach((ds) => {
drawDtmfRegionForSpan(ds, startPoint);
});
// TTS delay
const ttsSpans = getSpansByNameRegex(spans, /tts-generation/);
ttsSpans.forEach((tts) => {
drawTtsLatencyRegion(tts, startPoint);
});
// Gather verb hook delay
const verbHookSpans = getSpansByNameRegex(spans, /verb:hook/);
verbHookSpans
.filter((s) => {
const [httpBody] = getSpanAttributeByName(
s.attributes,
"http.body",
);
return (
httpBody.value.stringValue.includes(
'"reason":"speechDetected"',
) ||
httpBody.value.stringValue.includes('"reason":"dtmfDetected"')
);
})
.forEach((s) => {
drawVerbHookDelayRegion(s, startPoint);
});
}
}
};
const handleDeleteRecordSubmit = () => {
if (deleteRecordUrl) {
deleteRecord(deleteRecordUrl)
.then(() => {
setDeleteRecordUrl("");
toastSuccess("Successfully deleted record");
})
.catch((error) => {
toastError(error.msg);
});
}
};
useEffect(() => {
buildWavesurferRegion();
buildWavesufferRegion();
}, [jaegerRoot, isReady]);
useEffect(() => {
@@ -478,9 +235,8 @@ export const Player = ({ call }: PlayerProps) => {
}
useEffect(() => {
if (waveSurferRef.current !== null || !record) return;
waveSurferRegionsPluginRef.current = RegionsPlugin.create();
waveSurferRef.current = WaveSurfer.create({
if (waveSufferRef.current !== null || !record) return;
waveSufferRef.current = WaveSurfer.create({
container: `#${wavesurferId}`,
waveColor: "#da1c5c",
progressColor: "grey",
@@ -488,68 +244,62 @@ export const Player = ({ call }: PlayerProps) => {
cursorWidth: 1,
cursorColor: "lightgray",
normalize: true,
autoScroll: true,
splitChannels: [],
minPxPerSec: 100,
responsive: true,
fillParent: true,
splitChannels: true,
scrollParent: true,
plugins: [
waveSurferRegionsPluginRef.current,
RegionsPlugin.create({}),
TimelinePlugin.create({
timeInterval: 0.2,
primaryLabelInterval: 5,
secondaryLabelInterval: 1,
style: {
fontSize: "15px",
color: "#000000",
fontWeight: "bold",
},
container: `#${wavesurferTimelineId}`,
}),
],
});
waveSurferRef.current.load(record?.data_url);
waveSufferRef.current.load(record?.data_url);
// All event should be after load
waveSurferRef.current.on("finish", () => {
waveSufferRef.current.on("finish", () => {
setIsPlaying(false);
});
waveSurferRef.current.on("play", () => {
waveSufferRef.current.on("play", () => {
setIsPlaying(true);
});
waveSurferRef.current.on("pause", () => {
waveSufferRef.current.on("pause", () => {
setIsPlaying(false);
});
waveSurferRef.current.on("ready", () => {
waveSufferRef.current.on("ready", () => {
setIsReady(true);
setPlayBackTime(formatTime(waveSurferRef.current?.getDuration() || 0));
setPlayBackTime(formatTime(waveSufferRef.current?.getDuration() || 0));
});
waveSurferRef.current.on("audioprocess", () => {
setPlayBackTime(formatTime(waveSurferRef.current?.getCurrentTime() || 0));
waveSufferRef.current.on("audioprocess", () => {
setPlayBackTime(formatTime(waveSufferRef.current?.getCurrentTime() || 0));
});
}, [record]);
const togglePlayback = () => {
if (waveSurferRef.current) {
if (waveSufferRef.current) {
if (!isPlaying) {
waveSurferRef.current.play();
waveSufferRef.current.play();
} else {
waveSurferRef.current.pause();
waveSufferRef.current.pause();
}
}
};
const setPlaybackJump = (delta: number) => {
if (waveSurferRef.current) {
const idx = waveSurferRef.current.getCurrentTime() + delta;
if (waveSufferRef.current) {
const idx = waveSufferRef.current.getCurrentTime() + delta;
const value =
idx <= 0
? 0
: idx >= waveSurferRef.current.getDuration()
? waveSurferRef.current.getDuration() - 1
: idx;
waveSurferRef.current.setTime(value);
: idx >= waveSufferRef.current.getDuration()
? waveSufferRef.current.getDuration() - 1
: idx;
waveSufferRef.current.setCurrentTime(value);
setPlayBackTime(formatTime(value));
}
};
@@ -560,74 +310,57 @@ export const Player = ({ call }: PlayerProps) => {
<>
<div className="media-container">
<div id={wavesurferId} />
<div id={wavesurferTimelineId} />
<div className="media-container__center">
<strong>{playBackTime}</strong>
</div>
<div className="controll-btn-container">
<div className="controll-btn-container__placeholder"></div>
<div className="controll-btn-container__center">
<button
className="btnty"
type="button"
onClick={() => {
setPlaybackJump(-JUMP_DURATION);
}}
title="Jump left"
disabled={!isReady}
>
<Icon>
<Icons.ChevronsLeft />
</Icon>
</button>
<button
className="btnty"
type="button"
onClick={togglePlayback}
title="play/pause"
disabled={!isReady}
>
<Icon>{isPlaying ? <Icons.Pause /> : <Icons.Play />}</Icon>
</button>
<div className="media-container__center">
<button
className="btnty"
type="button"
onClick={() => {
setPlaybackJump(-JUMP_DURATION);
}}
title="Jump left"
disabled={!isReady}
>
<Icon>
<Icons.ChevronsLeft />
</Icon>
</button>
<button
className="btnty"
type="button"
onClick={togglePlayback}
title="play/pause"
disabled={!isReady}
>
<Icon>{isPlaying ? <Icons.Pause /> : <Icons.Play />}</Icon>
</button>
<button
className="btnty"
type="button"
onClick={() => {
setPlaybackJump(JUMP_DURATION);
}}
title="Jump right"
disabled={!isReady}
>
<Icon>
<Icons.ChevronsRight />
</Icon>
</button>
</div>
<div className="controll-btn-container__right">
<a
href={record.data_url}
download={record.file_name}
className="btnty"
title="Download record file"
>
<Icon>
<Icons.Download />
</Icon>
</a>
<button
type="button"
onClick={() => {
setDeleteRecordUrl(url || "");
}}
title="Delete record file"
>
<Icon>
<Icons.Trash2 />
</Icon>
</button>
</div>
<button
className="btnty"
type="button"
onClick={() => {
setPlaybackJump(JUMP_DURATION);
}}
title="Jump right"
disabled={!isReady}
>
<Icon>
<Icons.ChevronsRight />
</Icon>
</button>
<a
href={record.data_url}
download={record.file_name}
className="btnty"
title="Download record file"
>
<Icon>
<Icons.Download />
</Icon>
</a>
</div>
<label htmlFor="is_active" className="chk">
<input
@@ -637,20 +370,19 @@ export const Player = ({ call }: PlayerProps) => {
checked={regionChecked}
onChange={(e) => {
setRegionChecked(e.target.checked);
if (waveSurferRegionsPluginRef.current) {
const regionsList =
waveSurferRegionsPluginRef.current.getRegions();
if (waveSufferRef.current) {
const regionsList = waveSufferRef.current.regions.list;
for (const [, region] of Object.entries(regionsList)) {
region.element.style.display = e.target.checked ? "" : "none";
}
}
}}
/>
<div>Show latencies</div>
<div>Overlay STT and DTMF events</div>
</label>
</div>
{waveSurferRegionData && (
<ModalClose handleClose={() => setWaveSurferRegionData(null)}>
{waveSufferRegionData && (
<ModalClose handleClose={() => setWaveSufferRegionData(null)}>
<div className="spanDetailsWrapper__header">
<P>
<strong>Speech to text result</strong>
@@ -658,53 +390,43 @@ export const Player = ({ call }: PlayerProps) => {
</div>
<div className="spanDetailsWrapper">
<div className="spanDetailsWrapper__detailsWrapper">
{waveSurferRegionData.vendor && (
{waveSufferRegionData.vendor && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Vendor:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferRegionData.vendor}
{waveSufferRegionData.vendor}
</div>
</div>
)}
{waveSurferRegionData.confidence !== 0 && (
{waveSufferRegionData.confidence !== 0 && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Confidence:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferRegionData.confidence}
{waveSufferRegionData.confidence}
</div>
</div>
)}
{waveSurferRegionData.language_code && (
{waveSufferRegionData.language_code && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Language code:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferRegionData.language_code}
{waveSufferRegionData.language_code}
</div>
</div>
)}
{waveSurferRegionData.transcript && (
{waveSufferRegionData.transcript && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Transcript:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferRegionData.transcript}
</div>
</div>
)}
{waveSurferRegionData.latency && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Latency:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferRegionData.latency.toFixed(2)} seconds
{waveSufferRegionData.transcript}
</div>
</div>
)}
@@ -712,8 +434,8 @@ export const Player = ({ call }: PlayerProps) => {
</div>
</ModalClose>
)}
{waveSurferDtmfData && (
<ModalClose handleClose={() => setWaveSurferDtmfData(null)}>
{waveSufferDtmfData && (
<ModalClose handleClose={() => setWaveSufferDtmfData(null)}>
<div className="spanDetailsWrapper__header">
<P>
<strong>Dtmf result</strong>
@@ -726,7 +448,7 @@ export const Player = ({ call }: PlayerProps) => {
<strong>Dtmf:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferDtmfData.dtmf}
{waveSufferDtmfData.dtmf}
</div>
</div>
@@ -735,95 +457,13 @@ export const Player = ({ call }: PlayerProps) => {
<strong>Duration:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferDtmfData.duration}
{waveSufferDtmfData.duration}
</div>
</div>
</div>
</div>
</ModalClose>
)}
{waveSurferTtsLatencyData && (
<ModalClose handleClose={() => setWaveSurferTtsLatencyData(null)}>
<div className="spanDetailsWrapper__header">
<P>
<strong>Tts Latency</strong>
</P>
</div>
<div className="spanDetailsWrapper">
<div className="spanDetailsWrapper__detailsWrapper">
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Vendor:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferTtsLatencyData.vendor}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Latency:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferTtsLatencyData.latency}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>From Cache:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferTtsLatencyData.isCached}
</div>
</div>
</div>
</div>
</ModalClose>
)}
{waveSurferGatherSpeechVerbHookLatencyData && (
<ModalClose
handleClose={() => setWaveSurferGatherSpeechVerbHookLatencyData(null)}
>
<div className="spanDetailsWrapper__header">
<P>
<strong>Application Response Latency</strong>
</P>
</div>
<div className="spanDetailsWrapper">
<div className="spanDetailsWrapper__detailsWrapper">
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Status Code:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferGatherSpeechVerbHookLatencyData.statusCode}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Latency:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferGatherSpeechVerbHookLatencyData.latency}
</div>
</div>
</div>
</div>
</ModalClose>
)}
{deleteRecordUrl && (
<Modal
handleCancel={() => setDeleteRecordUrl("")}
handleSubmit={handleDeleteRecordSubmit}
>
<P>
Are you sure you want to delete the record for call{" "}
<strong>{call_sid}</strong>?
</P>
</Modal>
)}
</>
);
};

View File

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

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