Compare commits

...

23 Commits

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

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

* fix

* wip

* wip

* wip

* wip

* fix

* fix

* fix

* fix

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

* fix review comment

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

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

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

* fix typo

* fix reivew comments

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

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

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

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

18
.env
View File

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

View File

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

View File

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

1704
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "jambonz-webapp",
"description": "A simple provisioning web app for jambonz",
"version": "0.8.3",
"version": "0.8.4",
"license": "MIT",
"type": "module",
"engines": {
@@ -41,15 +41,18 @@
"deploy": "npm i && npm run build && npm run pm2"
},
"dependencies": {
"dayjs": "^1.11.5",
"@jambonz/ui-kit": "^0.0.21",
"dayjs": "^1.11.5",
"immutability-helper": "^3.1.1",
"react": "^18.0.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "^18.0.0",
"react-feather": "^2.0.10",
"react-router-dom": "^6.3.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"immutability-helper": "^3.1.1"
"wavesurfer.js": "^6.6.3",
"@stripe/react-stripe-js": "^2.1.1",
"@stripe/stripe-js": "^1.54.1"
},
"devDependencies": {
"@types/cors": "^2.8.12",
@@ -58,6 +61,7 @@
"@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",

View File

@@ -31,9 +31,10 @@ app.get(
for (let i = 0; i < 500; i++) {
const attempted_at = new Date(start.getTime() + i * increment);
const failed = 0 === i % 5;
const call_sid = nanoid();
const call: RecentCall = {
account_sid: req.params.account_sid,
call_sid: nanoid(),
call_sid,
from: "15083084809",
to: "18882349999",
answered: !failed,
@@ -49,6 +50,7 @@ app.get(
direction: 0 === i % 2 ? "inbound" : "outbound",
trunk: 0 === i % 2 ? "twilio" : "user",
trace_id: nanoid(),
recording_url: `http://127.0.0.1:3002/api/Accounts/${req.params.account_sid}/RecentCalls/${call_sid}/record`,
};
data.push(call);
}
@@ -137,6 +139,24 @@ app.get(
}
);
app.get(
"/api/Accounts/:account_sid/RecentCalls/:call_sid/record",
(req: Request, res: Response) => {
/** Sample pcap file from: https://wiki.wireshark.org/SampleCaptures#sip-and-rtp */
const wav: Buffer = fs.readFileSync(
path.resolve(process.cwd(), "server", "example.mp3")
);
res
.status(200)
.set({
"Content-Type": "audio/wav",
"Content-Disposition": "attachment",
})
.send(wav); // server: Buffer => client: Blob
}
);
app.get(
"/api/Accounts/:account_sid/RecentCalls/trace/:trace_id",
(req: Request, res: Response) => {

BIN
server/example.mp3 Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import type {
Currency,
LimitField,
LimitUnitOption,
PasswordSettings,
@@ -13,6 +14,17 @@ import type {
/** The API url is constructed with the docker containers `ip:port` */
interface JambonzWindowObject {
API_BASE_URL: string;
DISABLE_LCR: string;
DISABLE_JAEGER_TRACING: string;
DISABLE_CUSTOM_SPEECH: string;
ENABLE_FORGOT_PASSWORD: string;
ENABLE_HOSTED_SYSTEM: string;
DISABLE_CALL_RECORDING: string;
GITHUB_CLIENT_ID: string;
GOOGLE_CLIENT_ID: string;
BASE_URL: string;
DEFAULT_SERVICE_PROVIDER_SID: string;
STRIPE_PUBLISHABLE_KEY: string;
}
declare global {
@@ -29,24 +41,50 @@ export const API_BASE_URL =
export const DEV_BASE_URL = import.meta.env.VITE_DEV_BASE_URL;
/** Disable custom speech vendor*/
export const DISABLE_CUSTOM_SPEECH: boolean = JSON.parse(
import.meta.env.VITE_DISABLE_CUSTOM_SPEECH || "false"
);
export const DISABLE_CUSTOM_SPEECH: boolean =
window.JAMBONZ?.DISABLE_CUSTOM_SPEECH === "true" ||
JSON.parse(import.meta.env.VITE_DISABLE_CUSTOM_SPEECH || "false");
/** Enable Forgot Password */
export const ENABLE_FORGOT_PASSWORD: boolean = JSON.parse(
import.meta.env.VITE_ENABLE_FORGOT_PASSWORD || "false"
);
export const ENABLE_FORGOT_PASSWORD: boolean =
window.JAMBONZ?.ENABLE_FORGOT_PASSWORD === "true" ||
JSON.parse(import.meta.env.VITE_APP_ENABLE_FORGOT_PASSWORD || "false");
/** Enable Cloud version */
export const ENABLE_HOSTED_SYSTEM: boolean =
window.JAMBONZ?.ENABLE_HOSTED_SYSTEM === "true" ||
JSON.parse(import.meta.env.VITE_APP_ENABLE_HOSTED_SYSTEM || "false");
/** Disable Lcr */
export const DISABLE_LCR: boolean = JSON.parse(
import.meta.env.VITE_APP_LCR_DISABLED || "false"
);
export const DISABLE_LCR: boolean =
window.JAMBONZ?.DISABLE_LCR === "true" ||
JSON.parse(import.meta.env.VITE_APP_LCR_DISABLED || "false");
/** Disable jaeger tracing */
export const DISABLE_JAEGER_TRACING: boolean = JSON.parse(
import.meta.env.VITE_APP_JAEGER_TRACING_DISABLED || "false"
);
export const DISABLE_JAEGER_TRACING: boolean =
window.JAMBONZ?.DISABLE_JAEGER_TRACING === "true" ||
JSON.parse(import.meta.env.VITE_APP_JAEGER_TRACING_DISABLED || "false");
/** Enable Record All Call Feature */
export const DISABLE_CALL_RECORDING: boolean =
window.JAMBONZ?.DISABLE_CALL_RECORDING === "true" ||
JSON.parse(import.meta.env.VITE_APP_DISABLE_CALL_RECORDING || "false");
export const DEFAULT_SERVICE_PROVIDER_SID: string =
window.JAMBONZ?.DEFAULT_SERVICE_PROVIDER_SID ||
import.meta.env.VITE_APP_DEFAULT_SERVICE_PROVIDER_SID;
export const GITHUB_CLIENT_ID: string =
window.JAMBONZ?.GITHUB_CLIENT_ID || import.meta.env.VITE_APP_GITHUB_CLIENT_ID;
export const BASE_URL: string =
window.JAMBONZ?.BASE_URL || import.meta.env.VITE_APP_BASE_URL;
export const GOOGLE_CLIENT_ID: string =
window.JAMBONZ?.GOOGLE_CLIENT_ID || import.meta.env.VITE_APP_GOOGLE_CLIENT_ID;
export const STRIPE_PUBLISHABLE_KEY: string =
window.JAMBONZ?.STRIPE_PUBLISHABLE_KEY ||
import.meta.env.VITE_APP_STRIPE_PUBLISHABLE_KEY;
/** TCP Max Port */
export const TCP_MAX_PORT = 65535;
@@ -119,7 +157,36 @@ export const SIP_GATEWAY_PROTOCOL_OPTIONS = [
value: "tls/srtp",
},
];
/**
* Record bucket type
*/
export const BUCKET_VENDOR_AWS = "aws_s3";
export const BUCKET_VENDOR_GOOGLE = "google";
export const BUCKET_VENDOR_OPTIONS = [
{
name: "NONE",
value: "",
},
{
name: "AWS S3",
value: BUCKET_VENDOR_AWS,
},
{
name: "Google Cloud Storage",
value: BUCKET_VENDOR_GOOGLE,
},
];
export const AUDIO_FORMAT_OPTIONS = [
{
name: "mp3",
value: "mp3",
},
{
name: "wav",
value: "wav",
},
];
/** Password Length options */
export const PASSWORD_MIN = 8;
@@ -211,6 +278,16 @@ export const DEFAULT_PSWD_SETTINGS: PasswordSettings = {
require_special_character: 0,
};
export const PlanType = {
PAID: "paid",
TRIAL: "trial",
FREE: "free",
};
export const CurrencySymbol: Currency = {
usd: "$",
};
/** User scope values values */
export const USER_ADMIN = "admin";
export const USER_SP = "service_provider";
@@ -225,6 +302,9 @@ export const CRED_NOT_TESTED = "not tested";
export const CARRIER_REG_OK = "ok";
export const CARRIER_REG_FAIL = "fail";
export const PRIVACY_POLICY = "https://jambonz.org/privacy";
export const TERMS_OF_SERVICE = "https://jambonz.org/terms";
/** API base paths */
export const API_LOGIN = `${API_BASE_URL}/login`;
export const API_LOGOUT = `${API_BASE_URL}/logout`;
@@ -245,3 +325,12 @@ export const API_SYSTEM_INFORMATION = `${API_BASE_URL}/SystemInformation`;
export const API_LCRS = `${API_BASE_URL}/Lcrs`;
export const API_LCR_ROUTES = `${API_BASE_URL}/LcrRoutes`;
export const API_LCR_CARRIER_SET_ENTRIES = `${API_BASE_URL}/LcrCarrierSetEntries`;
export const API_TTS_CACHE = `${API_BASE_URL}/TtsCache`;
export const API_CLIENTS = `${API_BASE_URL}/Clients`;
export const API_REGISTER = `${API_BASE_URL}/register`;
export const API_ACTIVATION_CODE = `${API_BASE_URL}/ActivationCode`;
export const API_AVAILABILITY = `${API_BASE_URL}/Availability`;
export const API_PRICE = `${API_BASE_URL}/Prices`;
export const API_SUBSCRIPTIONS = `${API_BASE_URL}/Subscriptions`;
export const API_CHANGE_PASSWORD = `${API_BASE_URL}/change-password`;
export const API_SIGNIN = `${API_BASE_URL}/signin`;

View File

@@ -24,6 +24,15 @@ import {
API_LCR_ROUTES,
API_LCR_CARRIER_SET_ENTRIES,
API_LCRS,
API_TTS_CACHE,
API_CLIENTS,
API_REGISTER,
API_ACTIVATION_CODE,
API_AVAILABILITY,
API_PRICE,
API_SUBSCRIPTIONS,
API_CHANGE_PASSWORD,
API_SIGNIN,
} from "./constants";
import { ROUTE_LOGIN } from "src/router/routes";
import {
@@ -69,8 +78,20 @@ import type {
Lcr,
LcrRoute,
LcrCarrierSetEntry,
BucketCredential,
BucketCredentialTestResult,
Client,
RegisterRequest,
RegisterResponse,
ActivationCode,
CurrentUserData,
PriceInfo,
Subscription,
DeleteAccount,
ChangePassword,
SignIn,
} from "./types";
import { StatusCodes } from "./types";
import { Availability, StatusCodes } from "./types";
import { JaegerRoot } from "./jaeger-types";
/** Wrap all requests to normalize response handling */
@@ -82,6 +103,7 @@ const fetchTransport = <Type>(
try {
const response = await fetch(url, options);
const transport: FetchTransport<Type> = {
headers: response.headers,
status: response.status,
json: <Type>{},
};
@@ -157,7 +179,7 @@ const getAuthHeaders = () => {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(token && { Authorization: `Bearer ${token}` }),
};
};
@@ -233,6 +255,17 @@ export const deleteFetch = <Type>(url: string) => {
});
};
export const deleteFetchWithPayload = <Type, Payload>(
url: string,
payload: Payload
) => {
return fetchTransport<Type>(url, {
method: "DELETE",
headers: getAuthHeaders(),
body: JSON.stringify(payload),
});
};
/** All APIs need a wrapper utility that uses the FetchTransport */
export const postLogin = (payload: UserLoginPayload) => {
@@ -266,6 +299,16 @@ export const postAccount = (payload: Partial<Account>) => {
return postFetch<SidResponse, Partial<Account>>(API_ACCOUNTS, payload);
};
export const postAccountBucketCredentialTest = (
sid: string,
payload: Partial<BucketCredential>
) => {
return postFetch<BucketCredentialTestResult, Partial<BucketCredential>>(
`${API_ACCOUNTS}/${sid}/BucketCredentialTest`,
payload
);
};
export const postApplication = (payload: Partial<Application>) => {
return postFetch<SidResponse, Partial<Application>>(
API_APPLICATIONS,
@@ -393,6 +436,41 @@ export const postLcrCarrierSetEntry = (
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);
};
/** Named wrappers for `putFetch` */
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
@@ -506,6 +584,22 @@ export const putLcrCarrierSetEntries = (
);
};
export const putClient = (sid: string, payload: Partial<Client>) => {
return putFetch<EmptyResponse, Partial<Client>>(
`${API_CLIENTS}/${sid}`,
payload
);
};
export const putActivationCode = (
code: string,
payload: Partial<ActivationCode>
) => {
return putFetch<EmptyResponse, Partial<ActivationCode>>(
`${API_ACTIVATION_CODE}/${code}`,
payload
);
};
/** Named wrappers for `deleteFetch` */
export const deleteUser = (sid: string) => {
@@ -520,8 +614,11 @@ export const deleteApiKey = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_API_KEYS}/${sid}`);
};
export const deleteAccount = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_ACCOUNTS}/${sid}`);
export const deleteAccount = (sid: string, payload: Partial<DeleteAccount>) => {
return deleteFetchWithPayload<EmptyResponse, Partial<DeleteAccount>>(
`${API_ACCOUNTS}/${sid}`,
payload
);
};
export const deleteApplication = (sid: string) => {
@@ -577,6 +674,17 @@ export const deleteLcrRoute = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_LCR_ROUTES}/${sid}`);
};
export const deleteTtsCache = () => {
return deleteFetch<EmptyResponse>(API_TTS_CACHE);
};
export const deleteAccountTtsCache = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_BASE_URL}/Accounts/${sid}/TtsCache`);
};
export const deleteClient = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_CLIENTS}/${sid}`);
};
/** Named wrappers for `getFetch` */
export const getUser = (sid: string) => {
@@ -615,8 +723,26 @@ export const getLcrCarrierSetEtries = (sid: string) => {
);
};
export const getClients = () => {
return getFetch<Client[]>(API_CLIENTS);
};
export const getClient = (sid: string) => {
return getFetch<Client[]>(`${API_CLIENTS}/${sid}`);
};
export const getAvailability = (domain: string) => {
return getFetch<Availability>(
`${API_AVAILABILITY}?type=subdomain&value=${domain}`
);
};
/** 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);
@@ -635,11 +761,11 @@ export const getRecentCall = (sid: string, sipCallId: string) => {
);
};
export const getPcap = (sid: string, sipCallId: string) => {
export const getPcap = (sid: string, sipCallId: string, method: string) => {
return getBlob(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}/pcap`
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/pcap`
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
);
};
@@ -662,11 +788,15 @@ export const getServiceProviderRecentCall = (
);
};
export const getServiceProviderPcap = (sid: string, sipCallId: string) => {
export const getServiceProviderPcap = (
sid: string,
sipCallId: string,
method: string
) => {
return getBlob(
import.meta.env.DEV
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}/pcap`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}/pcap`
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
);
};
@@ -680,6 +810,10 @@ export const getAlerts = (sid: string, query: Partial<PageQuery>) => {
);
};
export const getPrice = () => {
return getFetch<PriceInfo[]>(API_PRICE);
};
/** Hooks for components to fetch data with refetch method */
/** :GET /{apiPath} -- this is generic for any fetch of data collections */

View File

@@ -37,6 +37,18 @@ export interface JaegerAttribute {
value: JaegerValue;
}
export interface WaveSufferSttResult {
vendor: string;
transcript: string;
confidence: number;
language_code: string;
}
export interface WaveSufferDtmfResult {
dtmf: string;
duration: string;
}
export interface JaegerValue {
stringValue: string;
doubleValue: string;

View File

@@ -51,6 +51,7 @@ export enum StatusCodes {
/** Fetch transport interfaces */
export interface FetchTransport<Type> {
headers: Headers;
status: StatusCodes;
json: Type;
blob?: Blob;
@@ -87,7 +88,7 @@ export interface SelectorOptions {
value: string;
}
export interface Pcap {
export interface DownloadedBlob {
data_url: string;
file_name: string;
}
@@ -102,6 +103,11 @@ export interface CredentialTestResult {
tts: CredentialTest;
}
export interface BucketCredentialTestResult {
status: CredentialStatus;
reason: string;
}
export interface LimitField {
label: string;
category: LimitCategories;
@@ -123,6 +129,10 @@ export interface SystemInformation {
monitoring_domain_name: string;
}
export interface TtsCache {
size: number;
}
/** API responses/payloads */
export interface User {
@@ -138,6 +148,7 @@ export interface User {
service_provider_name?: string | null;
initial_password?: string;
permissions?: UserPermissions[];
provider?: null | string;
}
export interface UserLogin {
@@ -176,6 +187,8 @@ export interface UserJWT {
export interface CurrentUserData {
user: User;
account?: Account;
subscription?: null | Subscription;
}
export interface ServiceProvider {
@@ -235,6 +248,7 @@ export interface Smpp {
export interface Account {
name: string;
sip_realm: null | string;
root_domain?: null | string;
account_sid: string;
webhook_secret: string;
siprec_hook_sid: null | string;
@@ -242,7 +256,55 @@ export interface Account {
registration_hook: null | WebHook;
service_provider_sid: string;
device_calling_application_sid: null | string;
lcr_sid: null | string;
record_all_calls: number;
record_format?: null | string;
bucket_credential: null | BucketCredential;
plan_type?: string;
device_to_call_ratio?: number;
trial_end_date?: null | string;
}
export interface Product {
price_id?: null | string;
product_sid?: null | string;
name?: string;
quantity?: number;
}
export interface Subscription {
action?: null | string;
payment_method_id?: null | string;
account_subscription_sid?: null | string;
stripe_customer_id?: null | string;
products?: null | Product[];
start_date?: string;
status?: string;
client_secret?: null | string;
last4?: null | string;
exp_month?: null | string;
exp_year?: null | string;
card_type?: null | string;
reason?: null | string;
dry_run?: boolean;
currency?: null | string;
prorated_cost?: number;
monthly_cost?: number;
next_invoice_date?: null | string;
}
export interface AwsTag {
Key: string;
Value: string;
}
export interface BucketCredential {
vendor: null | string;
region?: null | string;
name?: null | string;
access_key_id?: null | string;
secret_access_key?: null | string;
tags?: null | AwsTag[];
service_key?: null | string;
}
export interface Application {
@@ -258,6 +320,7 @@ export interface Application {
speech_synthesis_language: null | string;
speech_recognizer_vendor: null | Lowercase<Vendor>;
speech_recognizer_language: null | string;
record_all_calls: number;
}
export interface PhoneNumber {
@@ -294,6 +357,7 @@ export interface RecentCall {
direction: string;
trunk: string;
trace_id: string;
recording_url?: string;
}
export interface SpeechCredential {
@@ -425,6 +489,14 @@ export interface LcrCarrierSetEntry {
priority: number;
}
export interface Client {
client_sid?: null | string;
account_sid: null | string;
username: null | string;
password?: null | string;
is_active: boolean;
}
export interface PageQuery {
page: number;
count: number;
@@ -467,3 +539,121 @@ export interface EmptyResponse {
export interface TotalResponse {
total: number;
}
export interface RegisterRequest {
service_provider_sid: string;
provider: string;
oauth2_code?: string;
oauth2_state?: string;
oauth2_client_id?: string;
oauth2_redirect_uri?: string;
locationBeforeAuth?: string;
name?: string;
email?: string;
password?: string;
email_activation_code?: string;
inviteCode?: string;
}
export interface RegisterResponse {
jwt: string;
user_sid: string;
account_sid: string;
root_domain: string;
}
export interface ActivationCode {
user_sid: string;
type: string;
}
export interface Availability {
available: boolean;
}
export interface Invoice {
total: number;
currency: null | string;
next_payment_attempt: null | string;
}
export type Currency = {
[key: string]: null | string;
};
export interface Recurring {
aggregate_usage: null | string;
interval: null | string;
interval_count: number;
trial_period_days: null | string;
usage_type: string;
}
export interface Price {
billing_scheme: string;
currency: string;
recurring: Recurring;
stripe_price_id: null | string;
tiers_mode: null | string;
type: null | string;
unit_amount: number;
unit_amount_decimal: null | string;
}
export interface PriceInfo {
category: null | string;
description: null | string;
name: null | string;
prices: Price[];
product_sid: null | string;
stripe_product_id: null | string;
unit_label: null | string;
}
export interface StripeCustomerId {
stripe_customer_id: null | string;
}
export interface Tier {
up_to: number;
flat_amount: number;
unit_amount: number;
}
export interface ServiceData {
category: null | string;
name: null | string;
service: null | string;
fees: number;
feesLabel: null | string;
cost: number;
capacity: number;
invalid: boolean;
currency: null | string;
min: number;
max: number;
dirty: boolean;
visible: boolean;
required: boolean;
billing_scheme?: null | string;
stripe_price_id?: null | string;
unit_label?: null | string;
product_sid?: null | string;
stripe_product_id?: null | string;
tiers?: Tier[];
}
export interface DeleteAccount {
password: string;
}
export interface ChangePassword {
old_password: null | string;
new_password: null | string;
}
export interface SignIn {
link?: null | string;
jwt?: null | string;
account_sid?: null | string;
}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ type CheckzoneProps = {
id?: string;
name: string;
label: string;
labelNode?: React.ReactNode;
hidden?: boolean;
children: React.ReactNode;
initialCheck: boolean;
@@ -22,6 +23,7 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
id,
name,
label,
labelNode,
hidden = false,
children,
initialCheck,
@@ -47,21 +49,24 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
return (
<div className={classesTop}>
<label>
<input
ref={ref}
type="checkbox"
name={name}
id={id || name}
onChange={(e) => {
setChecked(e.target.checked);
<div className="label-container">
<input
ref={ref}
type="checkbox"
name={name}
id={id || name}
onChange={(e) => {
setChecked(e.target.checked);
if (handleChecked) {
handleChecked(e);
}
}}
checked={checked}
/>
<div>{label}</div>
if (handleChecked) {
handleChecked(e);
}
}}
checked={checked}
/>
{label && <div>{label}</div>}
{labelNode && labelNode}
</div>
</label>
{checked && <div className={classesIn}>{children}</div>}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,85 @@
import { Button, ButtonGroup, H1, MS } from "@jambonz/ui-kit";
import React, { 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 { Message } from "src/components/forms";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
export const EditSipRealm = () => {
const [name, setName] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const navigate = useNavigate();
const [userData] = useApiData<CurrentUserData>("Users/me");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const rootDomain = userData?.account?.root_domain;
const account_sid = userData?.account?.account_sid;
getAvailability(`${name}.${rootDomain}`)
.then(({ json }) => {
if (!json.available) {
setErrorMessage("That subdomain is not available.");
return;
}
postSipRealms(account_sid || "", `${name}.${rootDomain}`)
.then(() => {
navigate(`${ROUTE_INTERNAL_ACCOUNTS}/${account_sid}/edit`);
})
.catch((error) => {
setErrorMessage(error.msg);
});
})
.catch((error) => {
setErrorMessage(error.msg);
});
};
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 />
<input
id="name"
required
type="text"
name="name"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<label htmlFor="fqdn">
FQDN: {name}.{userData?.account?.root_domain}
</label>
</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>
Change Sip Realm
</Button>
</ButtonGroup>
</fieldset>
</form>
</Section>
</>
);
};
export default EditSipRealm;

View File

@@ -7,7 +7,7 @@ import { useApiData } from "src/api";
import { toastError, useSelectState } from "src/store";
import { AccountForm } from "./form";
import type { Account, Application, Limit } from "src/api/types";
import type { Account, Application, Limit, TtsCache } from "src/api/types";
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_APPLICATIONS,
@@ -25,6 +25,9 @@ export const EditAccount = () => {
`Accounts/${params.account_sid}/Limits`
);
const [apps] = useApiData<Application[]>("Applications");
const [ttsCache, ttsCacheFetcher] = useApiData<TtsCache>(
`Accounts/${params.account_sid}/TtsCache`
);
useScopedRedirect(
Scope.account,
@@ -50,6 +53,7 @@ export const EditAccount = () => {
apps={apps}
account={{ data, refetch, error }}
limits={{ data: limitsData, refetch: refetchLimits }}
ttsCache={{ data: ttsCache, refetch: ttsCacheFetcher }}
/>
<ApiKeys
path={`Accounts/${params.account_sid}/ApiKeys`}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { P, Button, ButtonGroup, MS } from "@jambonz/ui-kit";
import { Link, useNavigate } from "react-router-dom";
import React, { useState, useEffect, useRef } from "react";
import { P, Button, ButtonGroup, MS, Icon, H1 } from "@jambonz/ui-kit";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toastError, toastSuccess, useSelectState } from "src/store";
import {
@@ -10,6 +10,9 @@ import {
useApiData,
postAccountLimit,
deleteAccountLimit,
deleteAccountTtsCache,
postAccountBucketCredentialTest,
deleteAccount,
} from "src/api";
import { ClipBoard, Icons, Modal, Section, Tooltip } from "src/components";
import {
@@ -19,40 +22,78 @@ import {
Message,
ApplicationSelect,
LocalLimits,
FileUpload,
} from "src/components/forms";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import {
AUDIO_FORMAT_OPTIONS,
BUCKET_VENDOR_AWS,
BUCKET_VENDOR_GOOGLE,
BUCKET_VENDOR_OPTIONS,
CRED_OK,
CurrencySymbol,
DEFAULT_WEBHOOK,
DISABLE_CALL_RECORDING,
ENABLE_HOSTED_SYSTEM,
PlanType,
USER_ACCOUNT,
WEBHOOK_METHODS,
} from "src/api/constants";
import { MSG_REQUIRED_FIELDS, MSG_WEBHOOK_FIELDS } from "src/constants";
import type {
import {
WebHook,
Account,
Application,
WebhookMethod,
UseApiDataMap,
Limit,
TtsCache,
BucketCredential,
AwsTag,
Invoice,
CurrentUserData,
Carrier,
SpeechCredential,
} from "src/api/types";
import { hasLength } from "src/utils";
import { hasLength, hasValue } from "src/utils";
import { useRegionVendors } from "src/vendor";
import { GoogleServiceKey } from "src/vendor/types";
import { getObscuredGoogleServiceKey } from "../speech-services/utils";
import dayjs from "dayjs";
import { EditBoard } from "src/components/editboard";
import { ModalLoader } from "src/components/modal";
import { useAuth } from "src/router/auth";
type AccountFormProps = {
apps?: Application[];
limits?: UseApiDataMap<Limit[]>;
account?: UseApiDataMap<Account>;
ttsCache?: UseApiDataMap<TtsCache>;
};
export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
export const AccountForm = ({
apps,
limits,
account,
ttsCache,
}: AccountFormProps) => {
const params = useParams();
const navigate = useNavigate();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useApiData<Account[]>("Accounts");
const [invoice] = useApiData<Invoice>("Invoices");
const [userData] = useApiData<CurrentUserData>("Users/me");
const [userCarriers] = useApiData<Carrier[]>(`VoipCarriers`);
const [userSpeechs] = useApiData<SpeechCredential[]>(
`/Accounts/${params.account_sid}/SpeechCredentials`
);
const [name, setName] = useState("");
const [realm, setRealm] = useState("");
const [appId, setAppId] = useState("");
const [recId, setRecId] = useState("");
const { signout } = useAuth();
const [regHook, setRegHook] = useState<WebHook>(DEFAULT_WEBHOOK);
const [queueHook, setQueueHook] = useState<WebHook>(DEFAULT_WEBHOOK);
const [modal, setModal] = useState(false);
@@ -60,6 +101,34 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
const [initialRegHook, setInitialRegHook] = useState(false);
const [initialQueueHook, setInitialQueueHook] = useState(false);
const [localLimits, setLocalLimits] = useState<Limit[]>([]);
const [clearTtsCacheFlag, setClearTtsCacheFlag] = useState(false);
const [recordAllCalls, setRecordAllCalls] = useState(false);
const [initialCheckRecordAllCall, setInitialCheckRecordAllCall] =
useState(false);
const [bucketVendor, setBucketVendor] = useState("");
const [tmpBucketVendor, setTmpBucketVendor] = useState("");
const [recordFormat, setRecordFormat] = useState("mp3");
const [bucketRegion, setBucketRegion] = useState("us-east-1");
const [bucketName, setBucketName] = useState("");
const [tmpBucketName, setTmpBucketName] = useState("");
const [bucketAccessKeyId, setBucketAccessKeyId] = useState("");
const [bucketSecretAccessKey, setBucketSecretAccessKey] = useState("");
const [bucketCredentialChecked, setBucketCredentialChecked] = useState(false);
const [bucketTags, setBucketTags] = useState<AwsTag[]>([]);
const [bucketGoogleServiceKey, setBucketGoogleServiceKey] =
useState<GoogleServiceKey | null>(null);
const [tmpBucketGoogleServiceKey, setTmpBucketGoogleServiceKey] =
useState<GoogleServiceKey | null>(null);
const regions = useRegionVendors();
const [subscriptionDescription, setSubscriptionDescription] = useState("");
const [isDeleteAccount, setIsDeleteAccount] = useState(false);
const [requiresPassword, setRequiresPassword] = useState(true);
const [deleteAccountPasswd, setDeleteAccountPasswd] = useState("");
const [deleteMessage, setDeleteMessage] = useState("");
const [isDisableDeleteAccountButton, setIsDisableDeleteAccountButton] =
useState(false);
const deleteMessageRef = useRef<HTMLInputElement | null>(null);
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
/** This lets us map and render the same UI for each... */
const webhooks = [
@@ -95,6 +164,49 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
},
];
useEffect(() => {
if (
isDeleteAccount &&
deleteMessageRef.current &&
deleteMessageRef.current !== document.activeElement
) {
deleteMessageRef.current.focus();
}
}, [isDeleteAccount]);
const handleDeleteAccount = (e: React.FormEvent) => {
e.preventDefault();
if (deleteMessage !== "delete my account") {
toastError(
"You must type the delete message correctly in order to delete your account."
);
if (
deleteMessageRef.current &&
deleteMessageRef.current !== document.activeElement
) {
deleteMessageRef.current.focus();
}
return;
}
setIsDisableDeleteAccountButton(true);
setIsShowModalLoader(true);
deleteAccount(userData?.account?.account_sid || "", {
password: deleteAccountPasswd,
})
.then(() => {
signout();
})
.catch((error) => {
toastError(error.msg);
})
.finally(() => {
setIsDisableDeleteAccountButton(false);
setIsShowModalLoader(false);
});
};
const handleConfirm = (e: React.FormEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -105,6 +217,62 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
setModal(false);
};
const handleFile = (file: File) => {
const handleError = () => {
setBucketGoogleServiceKey(null);
setTmpBucketGoogleServiceKey(null);
toastError("Invalid service key file, could not parse as JSON.");
};
file
.text()
.then((text) => {
try {
const json: GoogleServiceKey = JSON.parse(text);
if (json.private_key && json.client_email) {
setBucketGoogleServiceKey(json);
setTmpBucketGoogleServiceKey(json);
} else {
setBucketGoogleServiceKey(null);
setTmpBucketGoogleServiceKey(null);
}
} catch (error) {
handleError();
}
})
.catch(() => {
handleError();
});
};
const handleTestBucketCredential = (e: React.FormEvent) => {
e.preventDefault();
if (!account || !account.data) return;
const cred: BucketCredential = {
vendor: bucketVendor,
name: bucketName,
...(bucketVendor === BUCKET_VENDOR_AWS && {
region: bucketRegion,
access_key_id: bucketAccessKeyId,
secret_access_key: bucketSecretAccessKey,
}),
...(bucketVendor === BUCKET_VENDOR_GOOGLE && {
service_key: JSON.stringify(bucketGoogleServiceKey),
}),
};
postAccountBucketCredentialTest(account?.data?.account_sid, cred).then(
({ json }) => {
if (json.status === CRED_OK) {
toastSuccess("Bucket Credential is valid.");
} else {
toastError(json.reason);
}
}
);
};
const handleRefresh = () => {
if (account && account.data) {
getAccountWebhook(account.data.account_sid)
@@ -139,6 +307,20 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
}
};
const handleClearCache = () => {
deleteAccountTtsCache(account?.data?.account_sid || "")
.then(() => {
if (ttsCache) {
ttsCache.refetch();
}
setClearTtsCacheFlag(false);
toastSuccess("Tts Cache successfully cleaned");
})
.catch((error) => {
toastError(error.msg);
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -183,12 +365,38 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
if (account && account.data) {
putAccount(account.data.account_sid, {
name,
sip_realm: realm || null,
...(!ENABLE_HOSTED_SYSTEM && { sip_realm: realm || null }),
webhook_secret: account.data.webhook_secret,
siprec_hook_sid: recId || null,
queue_event_hook: queueHook || account.data.queue_event_hook,
registration_hook: regHook || account.data.registration_hook,
device_calling_application_sid: appId || null,
record_all_calls: recordAllCalls ? 1 : 0,
record_format: recordFormat ? recordFormat : "mp3",
...(bucketVendor === BUCKET_VENDOR_AWS && {
bucket_credential: {
vendor: bucketVendor || null,
region: bucketRegion || "us-east-1",
name: bucketName || null,
access_key_id: bucketAccessKeyId || null,
secret_access_key: bucketSecretAccessKey || null,
...(hasLength(bucketTags) && { tags: bucketTags }),
},
}),
...(bucketVendor === BUCKET_VENDOR_GOOGLE && {
bucket_credential: {
vendor: bucketVendor || null,
service_key: JSON.stringify(bucketGoogleServiceKey),
name: bucketName || null,
...(hasLength(bucketTags) && { tags: bucketTags }),
},
}),
...(!bucketCredentialChecked && {
record_all_calls: 0,
bucket_credential: {
vendor: "none",
},
}),
})
.then(() => {
account.refetch();
@@ -263,11 +471,298 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
setInitialQueueHook(false);
}
}
if (tmpBucketVendor) {
setBucketVendor(tmpBucketVendor);
} else if (account.data.bucket_credential?.vendor) {
setBucketVendor(account.data.bucket_credential?.vendor);
}
if (tmpBucketName) {
setBucketName(tmpBucketName);
} else if (account.data.bucket_credential?.name) {
setBucketName(account.data.bucket_credential?.name);
}
if (account.data.bucket_credential?.access_key_id) {
setBucketAccessKeyId(account.data.bucket_credential?.access_key_id);
}
if (account.data.bucket_credential?.secret_access_key) {
setBucketSecretAccessKey(
account.data.bucket_credential?.secret_access_key
);
}
if (account.data.bucket_credential?.region) {
setBucketRegion(account.data.bucket_credential?.region);
}
if (account.data.record_all_calls) {
setRecordAllCalls(account.data.record_all_calls ? true : false);
}
setBucketCredentialChecked(
hasValue(bucketVendor) && bucketVendor.length !== 0
);
if (account.data.bucket_credential?.tags) {
setBucketTags(account.data.bucket_credential?.tags);
}
if (account.data.record_format) {
setRecordFormat(account.data.record_format || "mp3");
}
if (tmpBucketGoogleServiceKey) {
setBucketGoogleServiceKey(tmpBucketGoogleServiceKey);
} else if (account.data.bucket_credential?.service_key) {
setBucketGoogleServiceKey(
JSON.parse(account.data.bucket_credential?.service_key)
);
}
setInitialCheckRecordAllCall(
hasValue(bucketVendor) && bucketVendor.length !== 0
);
}
}, [account]);
if (ENABLE_HOSTED_SYSTEM) {
useEffect(() => {
if (userData && userData.user) {
setRequiresPassword(userData.user.provider === "local");
}
if (userData && userData.account) {
const pType = userData.account.plan_type;
const { products } = userData.subscription || {};
const registeredDeviceRecord = products
? products.find((item) => item.name === "registered device") || {
quantity: 0,
}
: { quantity: 0 };
const callSessionRecord = products
? products.find(
(item) => item.name === "concurrent call session"
) || { quantity: 0 }
: { quantity: 0 };
const quantity =
(userData.account.device_to_call_ratio || 0) *
(callSessionRecord.quantity || 0) +
(registeredDeviceRecord.quantity || 0);
const { trial_end_date } = userData.account || {};
switch (pType) {
case PlanType.TRIAL:
setSubscriptionDescription(
`You are currently on the Free plan (trial period). You are limited to ${
callSessionRecord.quantity
} simultaneous calls and ${quantity} registered devices.${
trial_end_date
? ` Your free trial will end on ${dayjs(
trial_end_date
).format("MMM DD, YYYY")}.`
: ""
}`
);
break;
case PlanType.PAID:
if (invoice) {
setSubscriptionDescription(
`Your paid subscription includes capacity for ${
callSessionRecord.quantity
} simultaneous calls, and ${quantity} registered devices. You are billed ${
CurrencySymbol[invoice.currency || "usd"]
}${(invoice.total || 0) / 100} on ${dayjs
.unix(Number(invoice.next_payment_attempt))
.format("MMM DD, YYYY")}.`
);
}
break;
case PlanType.FREE:
setSubscriptionDescription(
`You are currently on the Free plan (trial period expired). You are limited to ${callSessionRecord.quantity} simultaneous calls and ${quantity} registered devices`
);
break;
}
// Make sure Account page is alway scroll to top to see subscription
window.scrollTo(0, 0);
}
}, [userData, invoice]);
}
const updateBucketTags = (
index: number,
key: string,
value: typeof bucketTags[number][keyof AwsTag]
) => {
setBucketTags(
bucketTags.map((b, i) => (i === index ? { ...b, [key]: value } : b))
);
};
const addBucketTag = () => {
setBucketTags((curr) => [
...curr,
{
Key: "",
Value: "",
},
]);
};
return (
<>
{ENABLE_HOSTED_SYSTEM && (
<>
<Section>
<H1 className="h5">Your Subscription</H1>
<P>{subscriptionDescription}</P>
<br />
<div className="mast">
<ButtonGroup left>
<Button
type="button"
mainStyle="hollow"
subStyle="grey"
small
onClick={() => setIsDeleteAccount(true)}
>
Delete Account
</Button>
</ButtonGroup>
<ButtonGroup right>
{userData?.account?.plan_type === PlanType.PAID ? (
<>
<Button
small
as={Link}
to={`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/manage-payment`}
>
Manage Payment Info
</Button>
<Button
small
as={Link}
to={`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/modify-subscription`}
>
Modify My Subscription
</Button>
</>
) : (
<Button
as={Link}
to={`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/subscription`}
>
Upgrade to a Paid Subscription
</Button>
)}
</ButtonGroup>
</div>
</Section>
{isDeleteAccount && (
<Section slim>
<form
className="form form--internal"
onSubmit={handleDeleteAccount}
>
<fieldset>
<H1 className="h4">Delete Account</H1>
<P>
<span>
<strong>Warning!</strong>
</span>{" "}
This will permantly delete all of your data from our
database. You will not be able to restore your account. You
must {requiresPassword && "provide your password and"} type
delete my account into the Delete Message field.
</P>
</fieldset>
<fieldset>
{requiresPassword && (
<>
<label htmlFor="password">
Password<span>*</span>
</label>
<Passwd
id="delete_account_password"
name="delete_account_password"
value={deleteAccountPasswd}
placeholder="Password"
required
onChange={(e) => {
setDeleteAccountPasswd(e.target.value);
}}
/>
</>
)}
<label htmlFor="deleteMessage">
Delete Message<span>*</span>
</label>
<input
id="deleteMessage"
required
type="text"
name="deleteMessage"
placeholder="Delete Message"
value={deleteMessage}
ref={deleteMessageRef}
onChange={(e) => setDeleteMessage(e.target.value)}
/>
</fieldset>
<fieldset>
<ButtonGroup right>
<Button
subStyle="grey"
type="button"
onClick={() => setIsDeleteAccount(false)}
small
>
Cancel
</Button>
<Button
type="submit"
disabled={isDisableDeleteAccountButton}
small
>
PERMANENTLY DELETE MY ACCOUNT
</Button>
</ButtonGroup>
</fieldset>
</form>
</Section>
)}
{(!userCarriers ||
userCarriers.length === 0 ||
!userSpeechs ||
userSpeechs.length === 0) && (
<Section>
<H1 className="h5">Finish Account Setup</H1>
<H1 className="h6">To do</H1>
{(!userCarriers || userCarriers.length === 0) && (
<>
<br />
<div>
<span>
<Icons.Edit />
Add a <Link to="/internal/carriers">carrier</Link> to
route calls
</span>
</div>
</>
)}
{(!userSpeechs || userSpeechs.length === 0) && (
<>
<br />
<div>
<span>
<Icons.Edit />
Add <Link to="/internal/speech-services">
speech
</Link>{" "}
credentials for text-to-speech and speech-to-text
</span>
</div>
</>
)}
</Section>
)}
</>
)}
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<fieldset>
@@ -297,22 +792,34 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
onChange={(e) => setName(e.target.value)}
/>
</fieldset>
<fieldset>
<LocalLimits
data={limits && limits.data}
limits={[localLimits, setLocalLimits]}
/>
</fieldset>
{!ENABLE_HOSTED_SYSTEM && (
<fieldset>
<LocalLimits
data={limits && limits.data}
limits={[localLimits, setLocalLimits]}
/>
</fieldset>
)}
<fieldset>
<label htmlFor="sip_realm">SIP realm</label>
<input
id="sip_realm"
type="text"
name="sip_realm"
placeholder="The domain name that SIP devices will register with"
value={realm}
onChange={(e) => setRealm(e.target.value)}
/>
{ENABLE_HOSTED_SYSTEM ? (
<EditBoard
id="sip_realm"
name="sip_realm"
text={realm}
title="Change SIP Realm"
path={`/internal/accounts/${user?.account_sid}/sip-realm/edit`}
/>
) : (
<input
id="sip_realm"
type="text"
name="sip_realm"
placeholder="The domain name that SIP devices will register with"
value={realm}
onChange={(e) => setRealm(e.target.value)}
/>
)}
</fieldset>
{account && account.data && (
<fieldset>
@@ -446,6 +953,260 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
</fieldset>
);
})}
{ttsCache && (
<fieldset>
<ButtonGroup left>
<Button
onClick={(e: React.FormEvent) => {
e.preventDefault();
setClearTtsCacheFlag(true);
}}
small
disabled={ttsCache.data?.size === 0}
>
Clear TTS Cache
</Button>
</ButtonGroup>
<MS>{`There are ${
ttsCache.data ? ttsCache.data.size : 0
} cached TTS prompts`}</MS>
</fieldset>
)}
{!DISABLE_CALL_RECORDING && (
<>
<fieldset>
<Checkzone
hidden
name="bucket_credential"
label="Enable call recording"
initialCheck={initialCheckRecordAllCall}
handleChecked={(e) => {
setBucketCredentialChecked(e.target.checked);
}}
>
<div>
<label htmlFor="audio_format">Audio Format</label>
<Selector
id={"audio_format"}
name={"audio_format"}
value={recordFormat}
options={AUDIO_FORMAT_OPTIONS}
onChange={(e) => {
setRecordFormat(e.target.value);
}}
/>
</div>
<div>
<label htmlFor="vendor">
Bucket Vendor{recordAllCalls && <span>*</span>}
</label>
<Selector
required={recordAllCalls}
id={"record_bucket_vendor"}
name={"record_bucket_vendor"}
value={bucketVendor}
options={BUCKET_VENDOR_OPTIONS}
onChange={(e) => {
setBucketVendor(e.target.value);
setTmpBucketVendor(e.target.value);
}}
/>
</div>
<label htmlFor="bucket_name">
Bucket Name<span>*</span>
</label>
<input
id="bucket_name"
required
type="text"
name="bucket_name"
placeholder="Bucket"
value={bucketName}
onChange={(e) => {
setBucketName(e.target.value);
setTmpBucketName(e.target.value);
}}
/>
{bucketVendor === BUCKET_VENDOR_AWS && (
<>
{regions && regions["aws"] && (
<>
<label htmlFor="bucket_aws_region">
Region<span>*</span>
</label>
<Selector
id="region"
name="region"
value={bucketRegion}
required
options={[
{
name: "Select a region",
value: "",
},
].concat(regions["aws"])}
onChange={(e) => setBucketRegion(e.target.value)}
/>
</>
)}
<label htmlFor="bucket_aws_access_key">
Access key ID<span>*</span>
</label>
<input
id="bucket_aws_access_key"
required
type="text"
name="bucket_aws_access_key"
placeholder="Access Key ID"
value={bucketAccessKeyId}
onChange={(e) => {
setBucketAccessKeyId(e.target.value);
}}
/>
<label htmlFor="bucket_aws_secret_key">
Secret access key<span>*</span>
</label>
<Passwd
id="bucket_aws_secret_key"
required
name="bucketaws_secret_key"
placeholder="Secret Access Key"
value={bucketSecretAccessKey}
onChange={(e) => {
setBucketSecretAccessKey(e.target.value);
}}
/>
</>
)}
{bucketVendor === BUCKET_VENDOR_GOOGLE && (
<>
<label htmlFor="google_service_key">
Service key<span>*</span>
<Tooltip text="Provide a JSON key for a Service Account with APIs enabled for Cloud Storage and Storage Transfer API">
{" "}
</Tooltip>
</label>
<FileUpload
id="google_service_key"
name="google_service_key"
handleFile={handleFile}
placeholder="Choose a file"
required={!bucketGoogleServiceKey}
/>
{bucketGoogleServiceKey && (
<pre>
<code>
{JSON.stringify(
getObscuredGoogleServiceKey(
bucketGoogleServiceKey
),
null,
2
)}
</code>
</pre>
)}
</>
)}
<label htmlFor="aws_s3_tags">
{bucketVendor === BUCKET_VENDOR_AWS
? "S3"
: bucketVendor === BUCKET_VENDOR_GOOGLE
? "Google Cloud Storage"
: ""}{" "}
Tags
</label>
{hasLength(bucketTags) &&
bucketTags.map((b, i) => (
<div key={`s3_tags_${i}`} className="bucket_tag">
<div>
<div>
<input
id={`bucket_tag_name_${i}`}
name={`bucket_tag_name_${i}`}
type="text"
placeholder="Name"
required
value={b.Key}
onChange={(e) => {
updateBucketTags(i, "Key", e.target.value);
}}
/>
</div>
<div>
<input
id={`bucket_tag_value_${i}`}
name={`bucket_tag_value_${i}`}
type="text"
placeholder="Value"
required
value={b.Value}
onChange={(e) => {
updateBucketTags(i, "Value", e.target.value);
}}
/>
</div>
</div>
<button
className="btnty"
title="Delete Aws Tag"
type="button"
onClick={() => {
setBucketTags(
bucketTags.filter((g2, i2) => i2 !== i)
);
}}
>
<Icon>
<Icons.Trash2 />
</Icon>
</button>
</div>
))}
<ButtonGroup left>
<button
className="btnty"
type="button"
onClick={addBucketTag}
title="Add S3 Tags"
>
<Icon subStyle="teal">
<Icons.Plus />
</Icon>
</button>
</ButtonGroup>
<ButtonGroup left>
<Button
onClick={handleTestBucketCredential}
small
disabled={
!bucketName ||
(bucketVendor === BUCKET_VENDOR_AWS &&
(!bucketAccessKeyId || !bucketSecretAccessKey)) ||
(bucketVendor === BUCKET_VENDOR_GOOGLE &&
!bucketGoogleServiceKey)
}
>
Test
</Button>
</ButtonGroup>
<label htmlFor="record_all_call" className="chk">
<input
id="record_all_call"
name="record_all_call"
type="checkbox"
onChange={(e) => setRecordAllCalls(e.target.checked)}
checked={recordAllCalls}
/>
<Tooltip text="You can also record calls only to specific applications">
Record all calls for this account
</Tooltip>
</label>
</Checkzone>
</fieldset>
</>
)}
{message && (
<fieldset>
<Message message={message} />
@@ -453,14 +1214,17 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
)}
<fieldset>
<ButtonGroup left>
<Button
small
subStyle="grey"
as={Link}
to={ROUTE_INTERNAL_ACCOUNTS}
>
Cancel
</Button>
{user?.scope != USER_ACCOUNT && (
<Button
small
subStyle="grey"
as={Link}
to={ROUTE_INTERNAL_ACCOUNTS}
>
Cancel
</Button>
)}
<Button type="submit" small>
Save
</Button>
@@ -476,6 +1240,22 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
</P>
</Modal>
)}
{clearTtsCacheFlag && (
<Modal
handleSubmit={handleClearCache}
handleCancel={() => setClearTtsCacheFlag(false)}
>
<P>Are you sure you want to clean TTS cache for this account?</P>
</Modal>
)}
{isShowModalLoader && (
<ModalLoader>
<P>
Your requested changes are being processed. Please do not leave the
page or hit the back button until complete.
</P>
</ModalLoader>
)}
</>
);
};

View File

@@ -45,7 +45,7 @@ export const Accounts = () => {
return;
}
deleteAccount(account.account_sid)
deleteAccount(account.account_sid, {})
.then(() => {
refetch();
setAccount(null);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
import React from "react";
import { 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 = loadStripe(STRIPE_PUBLISHABLE_KEY);
export const Subscription = () => {
return (
<>
<Elements
stripe={stripePromise}
options={{
mode: "setup",
currency: "usd",
paymentMethodCreation: "manual",
}}
>
<SubscriptionForm />
</Elements>
</>
);
};
export default Subscription;

View File

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

View File

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

View File

@@ -33,7 +33,11 @@ import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_APPLICATIONS,
} from "src/router/routes";
import { DEFAULT_WEBHOOK, WEBHOOK_METHODS } from "src/api/constants";
import {
DEFAULT_WEBHOOK,
DISABLE_CALL_RECORDING,
WEBHOOK_METHODS,
} from "src/api/constants";
import type {
RecognizerVendors,
@@ -96,6 +100,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const [credentials] = useApiData<SpeechCredential[]>(apiUrl);
const [softTtsVendor, setSoftTtsVendor] = useState<VendorOptions[]>(vendors);
const [softSttVendor, setSoftSttVendor] = useState<VendorOptions[]>(vendors);
const [recordAllCalls, setRecordAllCalls] = useState(false);
/** This lets us map and render the same UI for each... */
const webhooks = [
@@ -178,6 +183,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
speech_synthesis_voice: synthVoice || null,
speech_recognizer_vendor: recogVendor || null,
speech_recognizer_language: recogLang || null,
record_all_calls: recordAllCalls ? 1 : 0,
};
if (application && application.data) {
@@ -243,6 +249,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
setLocation();
if (application && application.data) {
setApplicationName(application.data.name);
setRecordAllCalls(application.data.record_all_calls ? true : false);
if (!applicationJson) {
setApplicationJson(application.data.app_json || "");
}
@@ -683,6 +690,23 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
</Checkzone>
</fieldset>
)}
{!DISABLE_CALL_RECORDING &&
accounts?.filter((a) => a.account_sid === accountSid).length &&
!accounts?.filter((a) => a.account_sid === accountSid)[0]
.record_all_calls && (
<fieldset>
<label htmlFor="record_all_call" className="chk">
<input
id="record_all_call"
name="record_all_call"
type="checkbox"
onChange={(e) => setRecordAllCalls(e.target.checked)}
checked={recordAllCalls}
/>
<div>Record all calls</div>
</label>
</fieldset>
)}
{message && <fieldset>{<Message message={message} />}</fieldset>}
<fieldset>
<ButtonGroup left>

View File

@@ -776,7 +776,7 @@ export const CarrierForm = ({
Does your carrier require authentication on outbound calls?
</MS>
<label htmlFor="sip_username">
Username {sipPass || sipRegister ? <span>*</span> : ""}
Auth username {sipPass || sipRegister ? <span>*</span> : ""}
</label>
<input
id="sip_username"
@@ -831,7 +831,7 @@ export const CarrierForm = ({
required={sipRegister}
onChange={(e) => setSipRealm(e.target.value)}
/>
<label htmlFor="from_user">SIP from user</label>
<label htmlFor="from_user">Username</label>
<input
id="from_user"
name="from_user"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,179 @@
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 } from "src/api/types";
import {
AccountFilter,
Icons,
ScopedAccess,
SearchFilter,
Section,
Spinner,
} from "src/components";
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { Scope } from "src/store/types";
import { hasLength, hasValue, useFilteredResults } from "src/utils";
import ClientsDelete from "./delete";
import { USER_ACCOUNT } from "src/api/constants";
export const Clients = () => {
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [clients, refetch] = useApiData<Client[]>("Clients");
const [accountSid, setAccountSid] = useState("");
const [filter, setFilter] = useState("");
const [client, setClient] = useState<Client | null>();
const tmpFilteredClients = useMemo(() => {
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
return clients;
}
return clients
? clients.filter((c) => {
return accountSid
? c.account_sid === accountSid
: accounts
? accounts.map((a) => a.account_sid).includes(c.account_sid || "")
: false;
})
: [];
}, [accountSid, clients, accounts]);
const filteredClients = useFilteredResults(filter, tmpFilteredClients);
const handleDelete = () => {
if (client) {
deleteClient(client.client_sid || "")
.then(() => {
toastSuccess(
<>
Deleted outbound call route <strong>{client.username}</strong>
</>
);
setClient(null);
refetch();
})
.catch((error) => {
toastError(error.msg);
});
}
};
return (
<>
<section className="mast">
<H1 className="h2">Clients</H1>
<Link to={`${ROUTE_INTERNAL_CLIENTS}/add`} title="Add a client">
{" "}
<Icon>
<Icons.Plus />
</Icon>
</Link>
</section>
<section className="filters filters--spaced">
<SearchFilter
placeholder="Filter clients"
filter={[filter, setFilter]}
/>
<ScopedAccess user={user} scope={Scope.admin}>
<AccountFilter
account={[accountSid, setAccountSid]}
accounts={accounts}
label=""
defaultOption
/>
</ScopedAccess>
</section>
<Section {...(hasLength(filteredClients) && { slim: true })}>
<div className="list">
{!hasValue(filteredClients) && hasLength(accounts) ? (
<Spinner />
) : hasLength(filteredClients) ? (
filteredClients.map((c) => (
<div className="item" key={c.client_sid}>
<div className="item__info">
<div className="item__title">
<Link
to={`${ROUTE_INTERNAL_CLIENTS}/${c.client_sid}/edit`}
title="Edit outbound call routes"
className="i"
>
<strong>{c.username}</strong>
<Icons.ArrowRight />
</Link>
</div>
<div className="item__meta">
<div>
<div
className={`i txt--${c.is_active ? "teal" : "grey"}`}
>
{c.is_active ? (
<Icons.CheckCircle />
) : (
<Icons.XCircle />
)}
<span>{c.is_active ? "Active" : "Inactive"}</span>
</div>
</div>
<div>
<div
className={`i txt--${c.account_sid ? "teal" : "grey"}`}
>
<Icons.Activity />
<span>
{
accounts?.find(
(acct) => acct.account_sid === c.account_sid
)?.name
}
</span>
</div>
</div>
</div>
</div>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_CLIENTS}/${c.client_sid}/edit`}
title="Edit Client"
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete client"
onClick={() => setClient(c)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
</div>
))
) : (
<M>No Clients.</M>
)}
</div>
</Section>
<Section clean>
<Button small as={Link} to={`${ROUTE_INTERNAL_CLIENTS}/add`}>
Add client
</Button>
</Section>
{client && (
<ClientsDelete
client={client}
handleCancel={() => setClient(null)}
handleSubmit={handleDelete}
/>
)}
</>
);
};
export default Clients;

View File

@@ -122,6 +122,7 @@ export const Card = ({
className={`lcr lcr--route lcr-card lcr-card-${
isDragging ? "disappear" : "appear"
}`}
// eslint-disable-next-line react/no-unknown-property
handler-id={handlerId}
>
<div>

View File

@@ -183,7 +183,7 @@ export const Lcrs = () => {
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`}
title="Edit carrier"
title="Edit Client"
>
<Icons.Edit3 />
</Link>

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,14 +44,17 @@ export const JaegerDetail = ({ group }: JaegerDetailProps) => {
.format("DD/MM/YY HH:mm:ss.SSS")}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Duration:</strong>
{!(group.name && group.name.startsWith("dtmf:")) && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Duration:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{formattedDuration(group.durationMs)}
</div>
</div>
<div className="spanDetailsWrapper__details_body">
{formattedDuration(group.durationMs)}
</div>
</div>
)}
{group.attributes.map((attribute) => (
<div key={attribute.key} className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">

View File

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

View File

@@ -0,0 +1,469 @@
import React from "react";
import WaveSurfer from "wavesurfer.js";
import { useEffect, useRef, useState } from "react";
import { Icon, P } from "@jambonz/ui-kit";
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/src/plugin/regions";
import TimelinePlugin from "wavesurfer.js/src/plugin/timeline";
import { API_BASE_URL } from "src/api/constants";
import {
JaegerRoot,
JaegerSpan,
WaveSufferDtmfResult,
WaveSufferSttResult,
} from "src/api/jaeger-types";
import {
getSpanAttributeByName,
getSpansByName,
getSpansByNameRegex,
getSpansFromJaegerRoot,
} from "./utils";
type PlayerProps = {
call: RecentCall;
};
export const Player = ({ call }: PlayerProps) => {
const { recording_url, call_sid } = call;
const url =
recording_url && recording_url.startsWith("http://")
? recording_url
: `${API_BASE_URL}${recording_url}`;
const JUMP_DURATION = 15; //seconds
const [isPlaying, setIsPlaying] = useState(false);
const [isReady, setIsReady] = useState(false);
const [playBackTime, setPlayBackTime] = useState("");
const [jaegerRoot, setJeagerRoot] = useState<JaegerRoot>();
const [waveSufferRegionData, setWaveSufferRegionData] =
useState<WaveSufferSttResult | null>();
const [waveSufferDtmfData, setWaveSufferDtmfData] =
useState<WaveSufferDtmfResult | null>();
const [regionChecked, setRegionChecked] = useState(false);
const wavesurferId = `wavesurfer--${call_sid}`;
const wavesurferTimelineId = `timeline-${wavesurferId}`;
const waveSufferRef = useRef<WaveSurfer | null>(null);
const [record, setRecord] = useState<DownloadedBlob | null>(null);
const drawDtmfRegionForSpan = (s: JaegerSpan, startPoint: JaegerSpan) => {
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"
);
if (dtmfValue && durationValue) {
const start =
(s.startTimeUnixNano - startPoint.startTimeUnixNano) /
1_000_000_000;
const duration =
Number(durationValue.value.stringValue.replace("ms", "")) / 1_000;
// as duration of DTMF is short, cannot be shown in wavesuffer,
// adjust region width here.
const delta = duration <= 0.1 ? 0.1 : duration;
const end = start + delta;
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: WaveSufferDtmfResult = {
dtmf: dtmfValue.value.stringValue,
duration: durationValue.value.stringValue,
};
region.on("click", () => {
setWaveSufferDtmfData(att);
});
}
}
}
};
const changeRegionMouseStyle = (region: Region, channel = 0) => {
region.element.style.display = regionChecked ? "" : "none";
region.element.style.height = "49%";
region.element.style.top = channel === 0 ? "0" : "51%";
region.element.addEventListener("mouseenter", () => {
region.element.style.cursor = "pointer"; // Change to your desired cursor style
});
region.element.addEventListener("mouseleave", () => {
region.element.style.cursor = "default";
});
};
const drawSttRegionForSpan = (
s: JaegerSpan,
startPoint: JaegerSpan,
channel = 0
) => {
if (waveSufferRef.current) {
const r = waveSufferRef.current.regions.list[s.spanId];
if (!r) {
const start =
(s.startTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000 +
0.05; // add magic 0.01 second in each region start time to isolate 2 near regions
const end =
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
const 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: 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,
};
} else {
const [sttResolve] = getSpanAttributeByName(
s.attributes,
"stt.resolve"
);
if (sttResolve && sttResolve.value.stringValue === "timeout") {
att = {
vendor: "",
transcript: "None (speech session timeout)",
confidence: 0,
language_code: "",
};
} else {
att = {
vendor: "",
transcript:
"None (call disconnected or speech session terminated)",
confidence: 0,
language_code: "",
};
}
}
region.on("click", () => {
setWaveSufferRegionData(att);
});
}
}
};
const buildWavesufferRegion = () => {
if (jaegerRoot) {
const spans = getSpansFromJaegerRoot(jaegerRoot);
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, startPoint);
});
const transcribeSpans = getSpansByNameRegex(spans, /stt-listen:/);
transcribeSpans.forEach((cs) => {
// Channel start from 0
const channel = Number(cs.name.split(":")[1]);
drawSttRegionForSpan(
cs,
startPoint,
channel > 0 ? channel - 1 : channel
);
});
const dtmfSpans = getSpansByNameRegex(spans, /dtmf:/);
dtmfSpans.forEach((ds) => {
drawDtmfRegionForSpan(ds, startPoint);
});
}
}
};
useEffect(() => {
buildWavesufferRegion();
}, [jaegerRoot, isReady]);
useEffect(() => {
getBlob(url).then(({ blob, headers }) => {
if (blob) {
const ext = headers.get("Content-Type") === "audio/wav" ? "wav" : "mp3";
setRecord({
data_url: URL.createObjectURL(blob),
file_name: `callid-${call_sid}.${ext}`,
});
}
});
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
getJaegerTrace(call.account_sid, call.trace_id).then(({ json }) => {
if (json) {
setJeagerRoot(json);
}
});
}
}, []);
function formatTime(seconds: number) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds
.toString()
.padStart(2, "0")}`;
}
useEffect(() => {
if (waveSufferRef.current !== null || !record) return;
waveSufferRef.current = WaveSurfer.create({
container: `#${wavesurferId}`,
waveColor: "#da1c5c",
progressColor: "grey",
height: 50,
cursorWidth: 1,
cursorColor: "lightgray",
normalize: true,
responsive: true,
fillParent: true,
splitChannels: true,
scrollParent: true,
plugins: [
RegionsPlugin.create({}),
TimelinePlugin.create({
container: `#${wavesurferTimelineId}`,
}),
],
});
waveSufferRef.current.load(record?.data_url);
// All event should be after load
waveSufferRef.current.on("finish", () => {
setIsPlaying(false);
});
waveSufferRef.current.on("play", () => {
setIsPlaying(true);
});
waveSufferRef.current.on("pause", () => {
setIsPlaying(false);
});
waveSufferRef.current.on("ready", () => {
setIsReady(true);
setPlayBackTime(formatTime(waveSufferRef.current?.getDuration() || 0));
});
waveSufferRef.current.on("audioprocess", () => {
setPlayBackTime(formatTime(waveSufferRef.current?.getCurrentTime() || 0));
});
}, [record]);
const togglePlayback = () => {
if (waveSufferRef.current) {
if (!isPlaying) {
waveSufferRef.current.play();
} else {
waveSufferRef.current.pause();
}
}
};
const setPlaybackJump = (delta: number) => {
if (waveSufferRef.current) {
const idx = waveSufferRef.current.getCurrentTime() + delta;
const value =
idx <= 0
? 0
: idx >= waveSufferRef.current.getDuration()
? waveSufferRef.current.getDuration() - 1
: idx;
waveSufferRef.current.setCurrentTime(value);
setPlayBackTime(formatTime(value));
}
};
if (!record) return null;
return (
<>
<div className="media-container">
<div id={wavesurferId} />
<div id={wavesurferTimelineId} />
<div className="media-container__center">
<strong>{playBackTime}</strong>
</div>
<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>
<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
id={`is_active${call.call_sid}`}
name="is_active"
type="checkbox"
checked={regionChecked}
onChange={(e) => {
setRegionChecked(e.target.checked);
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>Overlay STT and DTMF events</div>
</label>
</div>
{waveSufferRegionData && (
<ModalClose handleClose={() => setWaveSufferRegionData(null)}>
<div className="spanDetailsWrapper__header">
<P>
<strong>Speech to text result</strong>
</P>
</div>
<div className="spanDetailsWrapper">
<div className="spanDetailsWrapper__detailsWrapper">
{waveSufferRegionData.vendor && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Vendor:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferRegionData.vendor}
</div>
</div>
)}
{waveSufferRegionData.confidence !== 0 && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Confidence:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferRegionData.confidence}
</div>
</div>
)}
{waveSufferRegionData.language_code && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Language code:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferRegionData.language_code}
</div>
</div>
)}
{waveSufferRegionData.transcript && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Transcript:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferRegionData.transcript}
</div>
</div>
)}
</div>
</div>
</ModalClose>
)}
{waveSufferDtmfData && (
<ModalClose handleClose={() => setWaveSufferDtmfData(null)}>
<div className="spanDetailsWrapper__header">
<P>
<strong>Dtmf result</strong>
</P>
</div>
<div className="spanDetailsWrapper">
<div className="spanDetailsWrapper__detailsWrapper">
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Dtmf:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferDtmfData.dtmf}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Duration:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSufferDtmfData.duration}
</div>
</div>
</div>
</div>
</ModalClose>
)}
</>
);
};

View File

@@ -0,0 +1,48 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
.wavesuffer {
position: relative;
}
.footer {
display: grid;
width: 100%;
margin-top: ui-vars.$px02;
&__buttons {
padding: ui-vars.$px02;
display: inline-flex;
flex-wrap: wrap;
gap: ui-vars.$px02;
}
}
.media-container {
border: 1px solid black;
border-radius: ui-vars.$px01;
padding: 13px;
position: relative;
&__center {
display: flex;
align-items: center;
justify-content: center;
grid-gap: ui-vars.$px01;
margin-top: ui-vars.$px01;
button {
background-color: transparent;
width: auto;
height: auto;
border: 0;
padding: 0;
}
.ico {
color: ui-vars.$white;
@include mixins.icosize();
}
}
}

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ import {
MSG_SOMETHING_WRONG,
MSG_CAPSLOCK,
MSG_PASSWD_MATCH,
MSG_PASSWD_CRITERIA,
} from "src/constants";
import type { IMessage } from "src/store/types";
@@ -50,7 +49,22 @@ export const CreatePassword = () => {
}
if (passwdSettings && !isValidPasswd(password, passwdSettings)) {
setMessage(MSG_PASSWD_CRITERIA);
setMessage(
<>
Password must:
<ul>
<li>
Be at least {passwdSettings.min_password_length} characters long
</li>
{passwdSettings.require_digit && (
<li>Contain at least one number</li>
)}
{passwdSettings.require_special_character && (
<li>Contain at least one special character</li>
)}
</ul>
</>
);
return;
}

View File

@@ -8,6 +8,7 @@ import { useNavigate } from "react-router-dom";
import { MSG_SOMETHING_WRONG } from "src/constants";
import { ROUTE_LOGIN } from "src/router/routes";
import { toastSuccess } from "src/store";
export const ForgotPassword = () => {
const [message, setMessage] = useState("");
@@ -22,6 +23,9 @@ export const ForgotPassword = () => {
postForgotPassword({ email })
.then((response) => {
if (response.status === StatusCodes.NO_CONTENT) {
toastSuccess(
"A password reset email has been sent to your email. Please check your inbox (and, possibly, spam folder) and follow the instructions to reset your password."
);
navigate(ROUTE_LOGIN);
} else {
setMessage(MSG_SOMETHING_WRONG);

View File

@@ -2,8 +2,8 @@ import React, { useEffect, useState } from "react";
import { Button, H1 } from "@jambonz/ui-kit";
import { useLocation, Navigate, Link } from "react-router-dom";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useAuth } from "src/router/auth";
import { toastError, toastSuccess } from "src/store";
import { getToken, parseJwt, useAuth } from "src/router/auth";
import {
SESS_FLASH_MSG,
SESS_OLD_PASSWORD,
@@ -13,15 +13,26 @@ import { Passwd, Message } from "src/components/forms";
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_CREATE_PASSWORD,
ROUTE_INTERNAL_APPLICATIONS,
ROUTE_FORGOT_PASSWORD,
ROUTE_REGISTER,
} from "src/router/routes";
import { USER_ACCOUNT, ENABLE_FORGOT_PASSWORD } from "src/api/constants";
import {
USER_ACCOUNT,
ENABLE_FORGOT_PASSWORD,
ENABLE_HOSTED_SYSTEM,
} from "src/api/constants";
import { Icons } from "src/components";
import { v4 as uuid } from "uuid";
import { setLocationBeforeOauth, setOauthState } from "src/store/localStore";
import { getGithubOauthUrl, getGoogleOauthUrl } from "./utils";
import { UserData } from "src/store/types";
export const Login = () => {
const state = uuid();
setOauthState(state);
setLocationBeforeOauth("/sign-in");
const { signin, authorized } = useAuth();
const location = useLocation();
const user = useSelectState("user");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [message, setMessage] = useState("");
@@ -56,13 +67,13 @@ export const Login = () => {
/>
);
}
const userData: UserData = parseJwt(getToken());
return (
<Navigate
to={
user?.scope !== USER_ACCOUNT
userData?.scope !== USER_ACCOUNT
? ROUTE_INTERNAL_ACCOUNTS
: ROUTE_INTERNAL_APPLICATIONS
: `${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`
}
state={{ from: location }}
replace
@@ -91,13 +102,36 @@ export const Login = () => {
/>
{message && <Message message={message} />}
<Button type="submit">Log in</Button>
{ENABLE_FORGOT_PASSWORD && (
<div>
<Link to={ROUTE_FORGOT_PASSWORD} title="Forgot Password">
<p>Forgot Password</p>
</Link>
{(ENABLE_FORGOT_PASSWORD || ENABLE_HOSTED_SYSTEM) && (
<div className={ENABLE_HOSTED_SYSTEM ? "mast" : ""}>
{ENABLE_HOSTED_SYSTEM && (
<Link to={ROUTE_REGISTER} title="Forgot Password">
<p>Register</p>
</Link>
)}
{ENABLE_FORGOT_PASSWORD && (
<Link to={ROUTE_FORGOT_PASSWORD} title="Forgot Password">
<p>Forgot Password</p>
</Link>
)}
</div>
)}
{ENABLE_HOSTED_SYSTEM && (
<>
<a href={getGoogleOauthUrl(state)} className="btn btn--hollow">
<div className="mast">
<Icons.Youtube />
<span>Sign In With Google</span>
</div>
</a>
<a href={getGithubOauthUrl(state)} className="btn btn--hollow">
<div className="mast">
<Icons.GitHub />
<span>Sign In With Github</span>
</div>
</a>
</>
)}
</form>
</>
);

View File

@@ -0,0 +1,95 @@
import React, { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { getMe, postRegister } from "src/api";
import {
DEFAULT_SERVICE_PROVIDER_SID,
GITHUB_CLIENT_ID,
GOOGLE_CLIENT_ID,
BASE_URL,
} from "src/api/constants";
import { Spinner } from "src/components";
import { setToken } from "src/router/auth";
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_LOGIN,
ROUTE_REGISTER,
ROUTE_REGISTER_SUB_DOMAIN,
} from "src/router/routes";
import { toastError } from "src/store";
import {
getLocationBeforeOauth,
getOauthState,
removeLocationBeforeOauth,
removeOauthState,
setRootDomain,
} from "src/store/localStore";
export const OauthCallback = () => {
const queryParams = new URLSearchParams(location.search);
const code = queryParams.get("code");
const newState = queryParams.get("state");
const originalState = getOauthState();
const previousLocation = getLocationBeforeOauth();
const { provider } = useParams();
const navigate = useNavigate();
useEffect(() => {
if (provider !== "github" && provider !== "google") {
toastError(`${provider} is not a valid OAuth provider`);
navigate(ROUTE_LOGIN);
return;
}
if (!code || !originalState || !newState || newState !== originalState) {
toastError("Invalid state");
navigate(ROUTE_LOGIN);
}
let oauth2_client_id;
let oauth2_redirect_uri;
if (provider === "github") {
oauth2_client_id = GITHUB_CLIENT_ID;
oauth2_redirect_uri = `${BASE_URL}/oauth-callback/github`;
} else if (provider === "google") {
oauth2_client_id = GOOGLE_CLIENT_ID;
oauth2_redirect_uri = `${BASE_URL}/oauth-callback/google`;
}
removeOauthState();
removeLocationBeforeOauth();
postRegister({
service_provider_sid: DEFAULT_SERVICE_PROVIDER_SID,
provider,
oauth2_code: code || "",
oauth2_state: originalState,
oauth2_client_id,
oauth2_redirect_uri,
locationBeforeAuth: previousLocation,
})
.then(({ json }) => {
setToken(json.jwt);
setRootDomain(json.root_domain);
if (previousLocation === "/register") {
navigate(ROUTE_REGISTER_SUB_DOMAIN);
} else {
getMe()
.then(({ json: me }) => {
if (!me.account?.sip_realm) {
navigate(ROUTE_REGISTER_SUB_DOMAIN);
} else {
navigate(`${ROUTE_INTERNAL_ACCOUNTS}/${json.account_sid}/edit`);
}
})
.catch((error) => {
toastError(error.msg);
});
}
})
.catch(() => {
navigate(ROUTE_REGISTER);
});
}, []);
return <Spinner />;
};
export default OauthCallback;

View File

@@ -0,0 +1,67 @@
import { Button, H1 } from "@jambonz/ui-kit";
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { postRegister } from "src/api";
import { DEFAULT_SERVICE_PROVIDER_SID } from "src/api/constants";
import { Passwd } from "src/components/forms";
import { ROUTE_LOGIN, ROUTE_REGISTER_EMAIL_VERIFY } from "src/router/routes";
import { generateActivationCode } from "./utils";
import { setToken } from "src/router/auth";
import { toastError } from "src/store";
import { setRootDomain } from "src/store/localStore";
export const RegisterEmail = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const navigate = useNavigate();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const email_activation_code = generateActivationCode();
postRegister({
service_provider_sid: DEFAULT_SERVICE_PROVIDER_SID,
provider: "local",
email,
password,
email_activation_code,
inviteCode: undefined, // reserved for inviteCode.
})
.then(({ json }) => {
setToken(json.jwt);
setRootDomain(json.root_domain);
navigate(ROUTE_REGISTER_EMAIL_VERIFY);
})
.catch((error) => {
toastError(error.msg);
});
};
return (
<>
<H1 className="h2">Register</H1>
<form className="form form--login" onSubmit={handleSubmit}>
<input
required
type="text"
name="email"
placeholder="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Passwd
required
name="password"
value={password}
placeholder="Password"
setValue={setPassword}
/>
<Button type="submit">Continue </Button>
<Link to={ROUTE_LOGIN} title="Go back">
<p>Go back</p>
</Link>
</form>
</>
);
};
export default RegisterEmail;

View File

@@ -0,0 +1,54 @@
import { Button, H1, MS } from "@jambonz/ui-kit";
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { putActivationCode } from "src/api";
import { getToken, parseJwt } from "src/router/auth";
import {
ROUTE_REGISTER_EMAIL,
ROUTE_REGISTER_SUB_DOMAIN,
} from "src/router/routes";
import { toastError } from "src/store";
import { UserData } from "src/store/types";
export const EmailVerify = () => {
const [code, setCode] = useState("");
const userData: UserData = parseJwt(getToken());
const navigate = useNavigate();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
putActivationCode(code, {
user_sid: userData.user_sid,
type: "email",
})
.then(() => {
navigate(ROUTE_REGISTER_SUB_DOMAIN);
})
.catch((error) => {
toastError(error.msg);
});
};
return (
<>
<H1 className="h2">Register</H1>
<form className="form form--login" onSubmit={handleSubmit}>
<MS>Please enter the code we just sent to your email</MS>
<input
required
type="text"
name="code"
placeholder="Verification Code"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
<Button type="submit">Continue </Button>
<Link to={ROUTE_REGISTER_EMAIL} title="Go back">
<p>Go back</p>
</Link>
</form>
</>
);
};
export default EmailVerify;

View File

@@ -0,0 +1,67 @@
import React from "react";
import { getGithubOauthUrl, getGoogleOauthUrl } from "./utils";
import { v4 as uuid } from "uuid";
import { setLocationBeforeOauth, setOauthState } from "src/store/localStore";
import { Icons } from "src/components";
import { Button, H1 } from "@jambonz/ui-kit";
import { PRIVACY_POLICY, TERMS_OF_SERVICE } from "src/api/constants";
import { Checkzone } from "src/components/forms";
import { Link } from "react-router-dom";
import { ROUTE_REGISTER_EMAIL } from "src/router/routes";
export const Register = () => {
const state = uuid();
setOauthState(state);
setLocationBeforeOauth("/register");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
};
return (
<>
<H1 className="h2">Register</H1>
<form className="form form--login" onSubmit={handleSubmit}>
<Checkzone
hidden
name="is_accepted"
label=""
labelNode={
<div>
I accept the
<a href={TERMS_OF_SERVICE}>
<span> Terms of Service </span>
</a>
and have read the
<a href={PRIVACY_POLICY}>
<span> Privacy Policy</span>
</a>
</div>
}
initialCheck={false}
>
<Button as={Link} to={ROUTE_REGISTER_EMAIL} mainStyle="hollow">
<div className="mast">
<Icons.Mail />
<span>Sign Up With Email</span>
</div>
</Button>
<a href={getGoogleOauthUrl(state)} className="btn btn--hollow">
<div className="mast">
<Icons.Youtube />
<span>Sign Up With Google</span>
</div>
</a>
<a href={getGithubOauthUrl(state)} className="btn btn--hollow">
<div className="mast">
<Icons.GitHub />
<span>Sign Up With Github</span>
</div>
</a>
</Checkzone>
</form>
</>
);
};
export default Register;

View File

@@ -0,0 +1,97 @@
import { Button, H1 } from "@jambonz/ui-kit";
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { postChangepassword, postSignIn } from "src/api";
import { Message, Passwd } from "src/components/forms";
import { setToken } from "src/router/auth";
import { ROUTE_LOGIN } from "src/router/routes";
import { toastError, toastSuccess } from "src/store";
export const ResetPassword = () => {
const params = useParams();
const resetId = params.id;
const [newPassword, setNewPassword] = useState("");
const [confirmNewPassword, setConfirmNewPassword] = useState("");
const [isDisableSubmitButton, setIsDisableSubmitButton] = useState(false);
const [message, setMessage] = useState("");
const navigate = useNavigate();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setMessage("");
if (newPassword !== confirmNewPassword) {
setMessage(
"The confirmation password does not match the new password. Please ensure both passwords are identical."
);
return;
}
if (newPassword.length < 6) {
setMessage("The password must be at least 7 characters long.");
return;
}
if (!/[a-zA-Z]/.test(newPassword)) {
setMessage("Password must contain a letter.");
}
setIsDisableSubmitButton(true);
postChangepassword({
old_password: resetId,
new_password: newPassword,
})
.then(() => {
toastSuccess("New password was successfully set.");
setToken("");
navigate(ROUTE_LOGIN);
})
.catch((error) => {
toastError(error.msg);
});
};
useEffect(() => {
postSignIn({
link: resetId,
})
.then(({ json }) => {
setToken(json.jwt || "");
})
.catch((error) => toastError(error.msg));
}, []);
return (
<>
<H1 className="h2">Reset Password</H1>
<form className="form form--login" onSubmit={handleSubmit}>
<label htmlFor="new_password">New password</label>
<Passwd
id="new_password"
name="new_password"
value={newPassword}
placeholder="New password"
required
onChange={(e) => {
setNewPassword(e.target.value);
}}
/>
<label htmlFor="confirm_new_password">Confirm new password</label>
<Passwd
id="confirm_new_password"
name="confirm_new_password"
value={confirmNewPassword}
placeholder="Confirm new password"
required
onChange={(e) => {
setConfirmNewPassword(e.target.value);
}}
/>
{message && <Message message={message} />}
<Button type="submit" disabled={isDisableSubmitButton}>
Save
</Button>
</form>
</>
);
};
export default ResetPassword;

View File

@@ -0,0 +1,63 @@
import { Button, H1, MS } from "@jambonz/ui-kit";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { getAvailability, postSipRealms } from "src/api";
import { Message } from "src/components/forms";
import { getToken, parseJwt } from "src/router/auth";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import { getRootDomain } from "src/store/localStore";
import { UserData } from "src/store/types";
export const RegisterChooseSubdomain = () => {
const [name, setName] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const rootDomain = getRootDomain();
const userData: UserData = parseJwt(getToken());
const navigate = useNavigate();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage("");
getAvailability(`${name}.${rootDomain}`)
.then(({ json }) => {
if (!json.available) {
setErrorMessage("That subdomain is not available.");
return;
}
postSipRealms(userData.account_sid || "", `${name}.${rootDomain}`)
.then(() => {
navigate(`${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`);
})
.catch((error) => {
setErrorMessage(error.msg);
});
})
.catch((error) => {
setErrorMessage(error.msg);
});
};
return (
<>
<H1 className="h2">Choose a subdomain</H1>
<form className="form form--login" onSubmit={handleSubmit}>
{errorMessage && <Message message={errorMessage} />}
<MS>
This will be the FQDN where your carrier will send calls, and where
you can register devices to. This can be changed at any time.
</MS>
<input
required
type="text"
name="username"
placeholder="Your Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button type="submit">Complete Registration </Button>
</form>
</>
);
};
export default RegisterChooseSubdomain;

View File

@@ -0,0 +1,24 @@
import {
GITHUB_CLIENT_ID,
GOOGLE_CLIENT_ID,
BASE_URL,
} from "src/api/constants";
export const getGithubOauthUrl = (state: string) => {
return `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&state=${state}&scope=user:email&allow_signup=false`;
};
export const getGoogleOauthUrl = (state: string) => {
return `https://accounts.google.com/o/oauth2/v2/auth?scope=email+profile&access_type=offline&include_granted_scopes=true&response_type=code&state=${state}&redirect_uri=${BASE_URL}/oauth-callback/google&client_id=${GOOGLE_CLIENT_ID}`;
};
const length = 6;
const digit = () => Math.floor(Math.random() * 10);
export function generateActivationCode() {
let activationCode = "";
for (let i = 0; i < length; i++) {
activationCode += digit();
}
return activationCode;
}

View File

@@ -4,13 +4,13 @@
import React, { useContext } from "react";
import { useNavigate } from "react-router-dom";
import { postLogin, postLogout } from "src/api";
import { getMe, postLogin, postLogout } from "src/api";
import { StatusCodes } from "src/api/types";
import {
ROUTE_CREATE_PASSWORD,
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_APPLICATIONS,
ROUTE_LOGIN,
ROUTE_REGISTER_SUB_DOMAIN,
} from "./routes";
import {
SESS_OLD_PASSWORD,
@@ -23,8 +23,13 @@ import {
} from "src/constants";
import type { UserLogin } from "src/api/types";
import { USER_ACCOUNT } from "src/api/constants";
import { ENABLE_HOSTED_SYSTEM, USER_ACCOUNT } from "src/api/constants";
import type { UserData } from "src/store/types";
import { toastError } from "src/store";
import {
removeLocationBeforeOauth,
removeOauthState,
} from "src/store/localStore";
interface SignIn {
(username: string, password: string): Promise<UserLogin>;
@@ -103,7 +108,23 @@ export const useProvideAuth = (): AuthStateContext => {
setToken(token);
userData = parseJwt(token);
if (response.json.force_change) {
if (ENABLE_HOSTED_SYSTEM) {
getMe()
.then(({ json }) => {
if (!json.account?.sip_realm) {
navigate(ROUTE_REGISTER_SUB_DOMAIN);
} else {
navigate(
userData.scope !== USER_ACCOUNT
? ROUTE_INTERNAL_ACCOUNTS
: `${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`
);
}
})
.catch((error) => {
toastError(error.msg);
});
} else if (response.json.force_change) {
sessionStorage.setItem(SESS_USER_SID, response.json.user_sid);
sessionStorage.setItem(SESS_OLD_PASSWORD, password);
navigate(ROUTE_CREATE_PASSWORD);
@@ -111,7 +132,7 @@ export const useProvideAuth = (): AuthStateContext => {
navigate(
userData.scope !== USER_ACCOUNT
? ROUTE_INTERNAL_ACCOUNTS
: ROUTE_INTERNAL_APPLICATIONS
: `${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`
);
}
@@ -131,19 +152,23 @@ export const useProvideAuth = (): AuthStateContext => {
}
reject(MSG_SOMETHING_WRONG);
})
.finally(() => {
removeOauthState();
removeLocationBeforeOauth();
});
});
};
const signout = () => {
window.location.href = ROUTE_LOGIN;
return new Promise((resolve, reject) => {
postLogout()
.then((response) => {
if (response.status === StatusCodes.OK) {
if (response.status === StatusCodes.NO_CONTENT) {
localStorage.clear();
sessionStorage.clear();
sessionStorage.setItem(SESS_FLASH_MSG, MSG_LOGGED_OUT);
window.location.href = ROUTE_LOGIN;
resolve(response.json);
}
})
@@ -151,6 +176,7 @@ export const useProvideAuth = (): AuthStateContext => {
localStorage.clear();
sessionStorage.clear();
sessionStorage.setItem(SESS_FLASH_MSG, MSG_LOGGED_OUT);
window.location.href = ROUTE_LOGIN;
if (error) {
reject(error);
}

View File

@@ -6,7 +6,10 @@ import { useSelectState } from "src/store";
import { Login, Layout as LoginLayout } from "src/containers/login";
import { Layout as InternalLayout } from "src/containers/internal";
import { NotFound } from "src/containers/notfound";
import { ENABLE_FORGOT_PASSWORD } from "src/api/constants";
import {
ENABLE_HOSTED_SYSTEM,
ENABLE_FORGOT_PASSWORD,
} from "src/api/constants";
/** Login */
import CreatePassword from "src/containers/login/create-password";
@@ -42,6 +45,18 @@ import MSTeamsTenantsEdit from "src/containers/internal/views/ms-teams-tenants/e
import Lcrs from "src/containers/internal/views/least-cost-routing";
import LcrsAdd from "src/containers/internal/views/least-cost-routing/add";
import LcrsEdit from "src/containers/internal/views/least-cost-routing/edit";
import Clients from "src/containers/internal/views/clients";
import ClientsAdd from "src/containers/internal/views/clients/add";
import ClientsEdit from "src/containers/internal/views/clients/edit";
import OauthCallback from "src/containers/login/oauth-callback";
import Register from "src/containers/login/register";
import RegisterEmail from "src/containers/login/register-email";
import EmailVerify from "src/containers/login/register-verify-email";
import RegisterChooseSubdomain from "src/containers/login/sub-domain";
import Subscription from "src/containers/internal/views/accounts/subscription";
import ManagePayment from "src/containers/internal/views/accounts/manage-payment";
import EditSipRealm from "src/containers/internal/views/accounts/edit-sip-realm";
import ResetPassword from "src/containers/login/reset-password";
export const Router = () => {
const toast = useSelectState("toast");
@@ -62,9 +77,29 @@ export const Router = () => {
}
/>
{ENABLE_FORGOT_PASSWORD && (
<Route path="forgot-password" element={<ForgotPassword />} />
<>
<Route path="forgot-password" element={<ForgotPassword />} />
<Route path="reset-password/:id" element={<ResetPassword />} />
</>
)}
{ENABLE_HOSTED_SYSTEM && (
<>
<Route path="register" element={<Register />} />
<Route path="register/email" element={<RegisterEmail />} />
<Route
path="register/email/verify-your-email"
element={<EmailVerify />}
/>
<Route
path="register/choose-a-subdomain"
element={<RegisterChooseSubdomain />}
/>
<Route
path="oauth-callback/:provider"
element={<OauthCallback />}
/>
</>
)}
{/* 404 page not found */}
<Route path="*" element={<NotFound />} />
</Route>
@@ -87,6 +122,26 @@ export const Router = () => {
path="accounts/:account_sid/edit"
element={<AccountEdit />}
/>
{ENABLE_HOSTED_SYSTEM && (
<>
<Route
path="accounts/:account_sid/subscription"
element={<Subscription />}
/>
<Route
path="accounts/:account_sid/manage-payment"
element={<ManagePayment />}
/>
<Route
path="accounts/:account_sid/modify-subscription"
element={<Subscription />}
/>
<Route
path="accounts/:account_sid/sip-realm/edit"
element={<EditSipRealm />}
/>
</>
)}
<Route path="applications" element={<Applications />} />
<Route path="applications/add" element={<ApplicationAdd />} />
<Route
@@ -138,6 +193,13 @@ export const Router = () => {
element={<LcrsEdit />}
/>
<Route path="clients" element={<Clients />} />
<Route path="clients/add" element={<ClientsAdd />} />
<Route
path="clients/:client_sid/edit"
element={<ClientsEdit />}
/>
{/* 404 page not found */}
<Route path="*" element={<NotFound />} />
</Route>

View File

@@ -1,9 +1,14 @@
export const ROUTE_LOGIN = "/";
export const ROUTE_REGISTER = "/register";
export const ROUTE_REGISTER_EMAIL = "/register/email";
export const ROUTE_REGISTER_EMAIL_VERIFY = "/register/email/verify-your-email";
export const ROUTE_REGISTER_SUB_DOMAIN = "/register/choose-a-subdomain";
export const ROUTE_CREATE_PASSWORD = "/create-password";
export const ROUTE_FORGOT_PASSWORD = "/forgot-password";
export const ROUTE_INTERNAL_USERS = "/internal/users";
export const ROUTE_INTERNAL_SETTINGS = "/internal/settings";
export const ROUTE_INTERNAL_ACCOUNTS = "/internal/accounts";
export const ROUTE_INTERNAL_CLIENTS = "/internal/clients";
export const ROUTE_INTERNAL_APPLICATIONS = "/internal/applications";
export const ROUTE_INTERNAL_RECENT_CALLS = "/internal/recent-calls";
export const ROUTE_INTERNAL_ALERTS = "/internal/alerts";

View File

@@ -58,6 +58,48 @@ export const removeQueryFilter = () => {
return localStorage.removeItem(storeQueryFilter);
};
/**Oauth2 */
const oauthStateKey = "oauth-state";
export const getOauthState = () => {
return localStorage.getItem(oauthStateKey) || "";
};
export const setOauthState = (token: string) => {
localStorage.setItem(oauthStateKey, token);
};
export const removeOauthState = () => {
return localStorage.removeItem(oauthStateKey);
};
const locationBeforeOauthKey = "location-before-oauth";
export const getLocationBeforeOauth = () => {
return localStorage.getItem(locationBeforeOauthKey) || "";
};
export const setLocationBeforeOauth = (token: string) => {
localStorage.setItem(locationBeforeOauthKey, token);
};
export const removeLocationBeforeOauth = () => {
return localStorage.removeItem(locationBeforeOauthKey);
};
// Email register
const rootDomainKey = "root-domain";
export const setRootDomain = (domain: string) => {
return localStorage.setItem(rootDomainKey, domain);
};
export const getRootDomain = () => {
return localStorage.getItem(rootDomainKey);
};
export const removeRootDomain = () => {
return localStorage.removeItem(rootDomainKey);
};
/**
* Methods to get/set the location from local storage
*/

View File

@@ -64,6 +64,9 @@ fieldset {
> button {
width: 100%;
}
> a {
width: 100%;
}
.msg {
width: 100%;
@@ -287,3 +290,7 @@ fieldset {
}
}
}
.bucket_tag {
@extend .lcr;
}

View File

@@ -34,7 +34,6 @@
padding-right: ui-vars.$px02;
}
}
&__empty {
display: flex;
}
@@ -112,4 +111,18 @@
}
}
}
&--col4--users {
.grid__row {
grid-template-columns: [col] 30% [col] 40% [col] 25% [col] 5%;
grid-template-rows: [row] auto [row] auto [row] [row] auto;
display: grid;
justify-content: space-between;
> div:last-child {
text-align: left;
padding-right: 0;
}
}
}
}

View File

@@ -173,6 +173,13 @@ details {
margin-top: ui-vars.$px02;
}
.pre-grid-white {
@extend .pre-grid;
background-color: ui-vars.$white;
color: ui-vars.$dark;
font-size: 1.2em;
}
.pcap {
margin-top: ui-vars.$px02;
}

View File

@@ -28,6 +28,8 @@ export const languages: VoiceLanguage[] = [
voices: [
{ value: "da-DK-Standard-A", name: "Standard-A (Female)" },
{ value: "da-DK-Wavenet-A", name: "Wavenet-A (Female)" },
{ value: "da-DK-Neural2-D", name: "Neural2-D (Female)" },
{ value: "da-DK-Neural2-F", name: "Neural2-F (Male)" },
],
},
{
@@ -58,6 +60,14 @@ export const languages: VoiceLanguage[] = [
{ value: "en-AU-Wavenet-B", name: "Wavenet-B (Male)" },
{ value: "en-AU-Wavenet-C", name: "Wavenet-C (Female)" },
{ value: "en-AU-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "en-AU-Neural2-A", name: "Neural2-A (Female)" },
{ value: "en-AU-Neural2-B", name: "Neural2-B (Male)" },
{ value: "en-AU-Neural2-C", name: "Neural2-C (Female)" },
{ value: "en-AU-Neural2-D", name: "Neural2-D (Male)" },
{ value: "en-AU-Polyglot-1", name: "Polyglot-1 (Male)" },
{ value: "en-AU-News-E", name: "News-E (Female)" },
{ value: "en-AU-News-F", name: "News-F (Female)" },
{ value: "en-AU-News-G", name: "News-G (Male)" },
],
},
{
@@ -86,6 +96,18 @@ export const languages: VoiceLanguage[] = [
{ value: "en-GB-Wavenet-B", name: "Wavenet-B (Male)" },
{ value: "en-GB-Wavenet-C", name: "Wavenet-C (Female)" },
{ value: "en-GB-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "en-GB-Neural2-A", name: "Neural2-A (Female)" },
{ value: "en-GB-Neural2-B", name: "Neural2-B (Male)" },
{ value: "en-GB-Neural2-C", name: "Neural2-C (Female)" },
{ value: "en-GB-Neural2-D", name: "Neural2-D (Male)" },
{ value: "en-GB-Neural2-F", name: "Neural2-F (Female)" },
{ value: "en-GB-News-G", name: "News-G (Female)" },
{ value: "en-GB-News-H", name: "News-H (Female)" },
{ value: "en-GB-News-I", name: "News-I (Female)" },
{ value: "en-GB-News-J", name: "News-J (Male)" },
{ value: "en-GB-News-K", name: "News-K (Male)" },
{ value: "en-GB-News-L", name: "News-L (Male)" },
{ value: "en-GB-News-M", name: "News-M (Male)" },
],
},
{
@@ -102,6 +124,22 @@ export const languages: VoiceLanguage[] = [
{ value: "en-US-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "en-US-Wavenet-E", name: "Wavenet-E (Female)" },
{ value: "en-US-Wavenet-F", name: "Wavenet-F (Female)" },
{ value: "en-US-Neural2-A", name: "Neural2-A (Male)" },
{ value: "en-US-Neural2-C", name: "Neural2-C (Female)" },
{ value: "en-US-Neural2-D", name: "Neural2-D (Male)" },
{ value: "en-US-Neural2-E", name: "Neural2-E (Female)" },
{ value: "en-US-Neural2-F", name: "Neural2-F (Female)" },
{ value: "en-US-Neural2-G", name: "Neural2-G (Female)" },
{ value: "en-US-Neural2-H", name: "Neural2-H (Female)" },
{ value: "en-US-Neural2-I", name: "Neural2-I (Male)" },
{ value: "en-US-Neural2-J", name: "Neural2-J (Male)" },
{ value: "en-US-Studio-M", name: "Studio-M (Male)" },
{ value: "en-US-Studio-O", name: "Studio-M (Female)" },
{ value: "en-US-Polyglot-1", name: "Polyglot-1 (Male)" },
{ value: "en-US-News-K", name: "News-K (Female)" },
{ value: "en-US-News-L", name: "News-L (Female)" },
{ value: "en-US-News-M", name: "News-M (Male)" },
{ value: "en-US-News-N", name: "News-N (Male)" },
],
},
{
@@ -110,6 +148,8 @@ export const languages: VoiceLanguage[] = [
voices: [
{ value: "fil-PH-Standard-A", name: "Standard-A (Female)" },
{ value: "fil-PH-Wavenet-A", name: "Wavenet-A (Female)" },
{ value: "fil-ph-Neural2-A", name: "Neural2-A (Female)" },
{ value: "fil-ph-Neural2-D", name: "Neural2-A (Male)" },
],
},
{
@@ -132,6 +172,10 @@ export const languages: VoiceLanguage[] = [
{ value: "fr-CA-Wavenet-B", name: "Wavenet-B (Male)" },
{ value: "fr-CA-Wavenet-C", name: "Wavenet-C (Female)" },
{ value: "fr-CA-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "fr-CA-Neural2-A", name: "Neural2-A (Female)" },
{ value: "fr-CA-Neural2-B", name: "Neural2-B (Male)" },
{ value: "fr-CA-Neural2-C", name: "Neural2-C (Female)" },
{ value: "fr-CA-Neural2-D", name: "Neural2-D (Male)" },
],
},
{
@@ -148,6 +192,12 @@ export const languages: VoiceLanguage[] = [
{ value: "fr-FR-Wavenet-C", name: "Wavenet-C (Female)" },
{ value: "fr-FR-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "fr-FR-Wavenet-E", name: "Wavenet-E (Female)" },
{ value: "fr-FR-Neural2-A", name: "Neural2-A (Female)" },
{ value: "fr-FR-Neural2-B", name: "Neural2-B (Male)" },
{ value: "fr-FR-Neural2-C", name: "Neural2-C (Female)" },
{ value: "fr-FR-Neural2-D", name: "Neural2-D (Male)" },
{ value: "fr-FR-Neural2-E", name: "Neural2-E (Female)" },
{ value: "fr-FR-Polyglot-1", name: "Polyglot-1 (Male)" },
],
},
{
@@ -164,6 +214,11 @@ export const languages: VoiceLanguage[] = [
{ value: "de-DE-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "de-DE-Wavenet-E", name: "Wavenet-E (Male)" },
{ value: "de-DE-Wavenet-F", name: "Wavenet-F (Female)" },
{ value: "de-DE-Neural2-B", name: "Neural2-B (Male)" },
{ value: "de-DE-Neural2-C", name: "Neural2-C (Female)" },
{ value: "de-DE-Neural2-D", name: "Neural2-D (Male)" },
{ value: "de-DE-Neural2-F", name: "Neural2-F (Female)" },
{ value: "de-DE-Polyglot-1", name: "Polyglot-1 (Male)" },
],
},
{
@@ -186,6 +241,10 @@ export const languages: VoiceLanguage[] = [
{ value: "hi-IN-Wavenet-B", name: "Wavenet-B (Male)" },
{ value: "hi-IN-Wavenet-C", name: "Wavenet-C (Male)" },
{ value: "hi-IN-Wavenet-D", name: "Wavenet-D (Female)" },
{ value: "hi-IN-Neural2-A", name: "Neural2-A (Female)" },
{ value: "hi-IN-Neural2-B", name: "Neural2-B (Male)" },
{ value: "hi-IN-Neural2-C", name: "Neural2-C (Male)" },
{ value: "hi-IN-Neural2-D", name: "Neural2-D (Female)" },
],
},
{
@@ -222,6 +281,8 @@ export const languages: VoiceLanguage[] = [
{ value: "it-IT-Wavenet-B", name: "Wavenet-B (Female)" },
{ value: "it-IT-Wavenet-C", name: "Wavenet-C (Male)" },
{ value: "it-IT-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "it-IT-Neural2-A", name: "Neural2-A (Female)" },
{ value: "it-IT-Neural2-C", name: "Neural2-C (Male)" },
],
},
{
@@ -236,6 +297,9 @@ export const languages: VoiceLanguage[] = [
{ value: "ja-JP-Wavenet-B", name: "Wavenet-B (Female)" },
{ value: "ja-JP-Wavenet-C", name: "Wavenet-C (Male)" },
{ value: "ja-JP-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "ja-JP-Neural2-B", name: "Neural2-B (Female)" },
{ value: "ja-JP-Neural2-C", name: "Neural2-C (Male)" },
{ value: "ja-JP-Neural2-D", name: "Neural2-D (Male)" },
],
},
{
@@ -250,6 +314,9 @@ export const languages: VoiceLanguage[] = [
{ value: "ko-KR-Wavenet-B", name: "Wavenet-B (Female)" },
{ value: "ko-KR-Wavenet-C", name: "Wavenet-C (Male)" },
{ value: "ko-KR-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "ko-KR-Neural2-A", name: "Neural2-A (Female)" },
{ value: "ko-KR-Neural2-B", name: "Neural2-B (Female)" },
{ value: "ko-KR-Neural2-C", name: "Neural2-C (Male)" },
],
},
{
@@ -316,6 +383,9 @@ export const languages: VoiceLanguage[] = [
voices: [
{ value: "pt-BR-Standard-A", name: "Standard-A (Female)" },
{ value: "pt-BR-Wavenet-A", name: "Wavenet-A (Female)" },
{ value: "pt-BR-Neural2-A", name: "Neural2-A (Female)" },
{ value: "pt-BR-Neural2-B", name: "Neural2-B (Male)" },
{ value: "pt-BR-Neural2-C", name: "Neural2-C (Female)" },
],
},
{
@@ -359,7 +429,31 @@ export const languages: VoiceLanguage[] = [
{
code: "es-ES",
name: "Spanish (Spain)",
voices: [{ value: "es-ES-Standard-A", name: "Standard-A (Female)" }],
voices: [
{ value: "es-ES-Standard-A", name: "Standard-A (Female)" },
{ value: "es-ES-Neural2-A", name: "Neural2-A (Female)" },
{ value: "es-ES-Neural2-B", name: "Neural2-B (Male)" },
{ value: "es-ES-Neural2-C", name: "Neural2-C (Female)" },
{ value: "es-ES-Neural2-D", name: "Neural2-D (Female)" },
{ value: "es-ES-Neural2-E", name: "Neural2-E (Female)" },
{ value: "es-ES-Neural2-F", name: "Neural2-F (Male)" },
{ value: "es-ES-Polyglot-1", name: "Polyglot-1 (Male)" },
],
},
{
code: "es-US",
name: "Spanish (US)",
voices: [
{ value: "es-US-Neural2-A", name: "Neural2-A (Female)" },
{ value: "es-US-Neural2-B", name: "Neural2-B (Male)" },
{ value: "es-US-Neural2-C", name: "Neural2-C (Male)" },
{ value: "es-US-Studio-B", name: "Studio-B (Male)" },
{ value: "es-US-Polyglot-1", name: "Polyglot-1 (Male)" },
{ value: "es-US-News-D", name: "News-D (Male)" },
{ value: "es-US-News-E", name: "News-E (Male)" },
{ value: "es-US-News-F", name: "News-F (Female)" },
{ value: "es-US-News-G", name: "News-G (Female)" },
],
},
{
code: "sv-SE",
@@ -393,6 +487,11 @@ export const languages: VoiceLanguage[] = [
{ value: "uk-UA-Wavenet-A", name: "Wavenet-A (Female)" },
],
},
{
code: "th-TH",
name: "Thai (Thailand)",
voices: [{ value: "th-TH-Neural2-C", name: "Neural2-C (Female)" }],
},
{
code: "vi-VN",
name: "Vietnamese (Vietnam)",
@@ -405,6 +504,8 @@ export const languages: VoiceLanguage[] = [
{ value: "vi-VN-Wavenet-B", name: "Wavenet-B (Male)" },
{ value: "vi-VN-Wavenet-C", name: "Wavenet-C (Female)" },
{ value: "vi-VN-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "vi-VN-Neural2-A", name: "Neural2-A (Female)" },
{ value: "vi-VN-Neural2-D", name: "Neural2-D (Male)" },
],
},
];