mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2026-01-25 02:08:19 +00:00
Compare commits
23 Commits
fix/admin_
...
v0.8.5-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08d1293e34 | ||
|
|
4eb2281b9a | ||
|
|
61bd1f9bab | ||
|
|
16629ba508 | ||
|
|
63f8a82443 | ||
|
|
9ce1d83c8f | ||
|
|
961b7ecccb | ||
|
|
3fb63c82ac | ||
|
|
cb2d5926b2 | ||
|
|
595e900468 | ||
|
|
0dd9548600 | ||
|
|
96ffce8cd1 | ||
|
|
b1fe033c12 | ||
|
|
a8d12546d9 | ||
|
|
724d86821d | ||
|
|
f91bbe9245 | ||
|
|
91625612d5 | ||
|
|
fbe71925b4 | ||
|
|
377fd40e2c | ||
|
|
af37066201 | ||
|
|
fffd86619d | ||
|
|
77c270e078 | ||
|
|
54ff53817f |
18
.env
18
.env
@@ -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"
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -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,')
|
||||
|
||||
@@ -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
1704
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -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",
|
||||
|
||||
@@ -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
BIN
server/example.mp3
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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`;
|
||||
|
||||
154
src/api/index.ts
154
src/api/index.ts
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
194
src/api/types.ts
194
src/api/types.ts
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
39
src/components/editboard/index.tsx
Normal file
39
src/components/editboard/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icons } from "src/components/icons";
|
||||
|
||||
type EditBoardProps = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
text: string;
|
||||
path: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export const EditBoard = ({
|
||||
text,
|
||||
id = "",
|
||||
name = "",
|
||||
path,
|
||||
title,
|
||||
}: EditBoardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const handleClick = () => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="clipboard inpbtn">
|
||||
<input id={id} name={name} type="text" readOnly value={text} />
|
||||
<button
|
||||
className="btnty"
|
||||
type="button"
|
||||
title={title ? title : "Edit"}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icons.Edit />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,14 +9,15 @@ import "./styles.scss";
|
||||
type TooltipProps = {
|
||||
text: IMessage;
|
||||
children: React.ReactNode;
|
||||
subStyle?: string;
|
||||
};
|
||||
|
||||
export const Tooltip = ({ text, children }: TooltipProps) => {
|
||||
export const Tooltip = ({ text, children, subStyle }: TooltipProps) => {
|
||||
return (
|
||||
<div className="tooltip">
|
||||
<div className="tooltip__reveal">{text}</div>
|
||||
{children}
|
||||
<Icons.HelpCircle />
|
||||
{subStyle === "info" ? <Icons.Info /> : <Icons.HelpCircle />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
export const TOAST_TIME = 3000;
|
||||
export const TOAST_TIME = 5000;
|
||||
export const SESS_FLASH_MSG = "SESS_FLASH_MSG";
|
||||
export const SESS_USER_SID = "SESS_USER_SID";
|
||||
export const SESS_OLD_PASSWORD = "SESS_OLD_PASSWORD";
|
||||
@@ -13,16 +13,6 @@ export const MSG_PASSWD_MATCH = "Passwords do not match";
|
||||
export const MSG_SERVER_DOWN = "The server cannot be reached";
|
||||
export const MSG_LOGGED_OUT = "You've successfully logged out.";
|
||||
export const MSG_MUST_LOGIN = "You must log in to view that page";
|
||||
export const MSG_PASSWD_CRITERIA = (
|
||||
<>
|
||||
Password must:
|
||||
<ul>
|
||||
<li>Be at least 6 characters</li>
|
||||
<li>Contain at least one letter</li>
|
||||
<li>Contain at least one number</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
export const MSG_REQUIRED_FIELDS = (
|
||||
<>
|
||||
Fields marked with an asterisk<span>*</span> are required.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
85
src/containers/internal/views/accounts/edit-sip-realm.tsx
Normal file
85
src/containers/internal/views/accounts/edit-sip-realm.tsx
Normal 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;
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ export const Accounts = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteAccount(account.account_sid)
|
||||
deleteAccount(account.account_sid, {})
|
||||
.then(() => {
|
||||
refetch();
|
||||
setAccount(null);
|
||||
|
||||
187
src/containers/internal/views/accounts/manage-payment-form.tsx
Normal file
187
src/containers/internal/views/accounts/manage-payment-form.tsx
Normal 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;
|
||||
23
src/containers/internal/views/accounts/manage-payment.tsx
Normal file
23
src/containers/internal/views/accounts/manage-payment.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Elements } from "@stripe/react-stripe-js";
|
||||
import { stripePromise } from "./subscription";
|
||||
import ManagePaymentForm from "./manage-payment-form";
|
||||
import React from "react";
|
||||
|
||||
export const ManagePayment = () => {
|
||||
return (
|
||||
<>
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
mode: "setup",
|
||||
currency: "usd",
|
||||
paymentMethodCreation: "manual",
|
||||
}}
|
||||
>
|
||||
<ManagePaymentForm />
|
||||
</Elements>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagePayment;
|
||||
629
src/containers/internal/views/accounts/subscription-form.tsx
Normal file
629
src/containers/internal/views/accounts/subscription-form.tsx
Normal 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;
|
||||
27
src/containers/internal/views/accounts/subscription.tsx
Normal file
27
src/containers/internal/views/accounts/subscription.tsx
Normal 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;
|
||||
59
src/containers/internal/views/alerts/alert-detail-item.tsx
Normal file
59
src/containers/internal/views/alerts/alert-detail-item.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import dayjs from "dayjs";
|
||||
import React, { useState } from "react";
|
||||
import { Alert } from "src/api/types";
|
||||
import { Icons } from "src/components";
|
||||
|
||||
type AlertDetailsItemProps = {
|
||||
alert: Alert;
|
||||
};
|
||||
|
||||
export const AlertDetailItem = ({ alert }: AlertDetailsItemProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="item">
|
||||
<details
|
||||
className="clean"
|
||||
onToggle={(e: React.BaseSyntheticEvent) => {
|
||||
if (e.target.open && !open) {
|
||||
setOpen(e.target.open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<summary className="txt--jam">
|
||||
<div className="item__info">
|
||||
<div className="item__title">
|
||||
<strong>
|
||||
{dayjs(alert.time).format("YYYY MM.DD hh:mm:ss a")}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="item__meta">
|
||||
<div>
|
||||
<div className="i txt--teal">
|
||||
<Icons.AlertCircle />
|
||||
<span>{alert.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="item__details">
|
||||
<div className="pre-grid">
|
||||
{Object.keys(alert).map((key) => (
|
||||
<React.Fragment key={key}>
|
||||
<div>{key}:</div>
|
||||
<div>
|
||||
{alert[key as keyof typeof alert]
|
||||
? String(alert[key as keyof typeof alert])
|
||||
: "null"}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertDetailItem;
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Section,
|
||||
SelectFilter,
|
||||
Spinner,
|
||||
Icons,
|
||||
} from "src/components";
|
||||
|
||||
import type { Account, Alert, PageQuery } from "src/api/types";
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
getQueryFilter,
|
||||
setLocation,
|
||||
} from "src/store/localStore";
|
||||
import AlertDetailItem from "./alert-detail-item";
|
||||
|
||||
export const Alerts = () => {
|
||||
const user = useSelectState("user");
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
14
src/containers/internal/views/clients/add.tsx
Normal file
14
src/containers/internal/views/clients/add.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { H1 } from "@jambonz/ui-kit";
|
||||
import React from "react";
|
||||
import ClientsForm from "./form";
|
||||
|
||||
export const ClientsAdd = () => {
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Add client</H1>
|
||||
<ClientsForm />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsAdd;
|
||||
28
src/containers/internal/views/clients/delete.tsx
Normal file
28
src/containers/internal/views/clients/delete.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { P } from "@jambonz/ui-kit";
|
||||
import React from "react";
|
||||
import { Client } from "src/api/types";
|
||||
import { Modal } from "src/components";
|
||||
|
||||
type ClientsDeleteProps = {
|
||||
client: Client;
|
||||
handleCancel: () => void;
|
||||
handleSubmit: () => void;
|
||||
};
|
||||
export const ClientsDelete = ({
|
||||
client,
|
||||
handleCancel,
|
||||
handleSubmit,
|
||||
}: ClientsDeleteProps) => {
|
||||
return (
|
||||
<>
|
||||
<Modal handleCancel={handleCancel} handleSubmit={handleSubmit}>
|
||||
<P>
|
||||
Are you sure you want to delete the client{" "}
|
||||
<strong>{client.username}</strong>?
|
||||
</P>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsDelete;
|
||||
30
src/containers/internal/views/clients/edit.tsx
Normal file
30
src/containers/internal/views/clients/edit.tsx
Normal 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;
|
||||
215
src/containers/internal/views/clients/form.tsx
Normal file
215
src/containers/internal/views/clients/form.tsx
Normal 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;
|
||||
179
src/containers/internal/views/clients/index.tsx
Normal file
179
src/containers/internal/views/clients/index.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
469
src/containers/internal/views/recent-calls/player.tsx
Normal file
469
src/containers/internal/views/recent-calls/player.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
48
src/containers/internal/views/recent-calls/styles.scss
Normal file
48
src/containers/internal/views/recent-calls/styles.scss
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/containers/internal/views/recent-calls/utils.ts
Normal file
46
src/containers/internal/views/recent-calls/utils.ts
Normal 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);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
95
src/containers/login/oauth-callback.tsx
Normal file
95
src/containers/login/oauth-callback.tsx
Normal 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;
|
||||
67
src/containers/login/register-email.tsx
Normal file
67
src/containers/login/register-email.tsx
Normal 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;
|
||||
54
src/containers/login/register-verify-email.tsx
Normal file
54
src/containers/login/register-verify-email.tsx
Normal 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;
|
||||
67
src/containers/login/register.tsx
Normal file
67
src/containers/login/register.tsx
Normal 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;
|
||||
97
src/containers/login/reset-password.tsx
Normal file
97
src/containers/login/reset-password.tsx
Normal 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;
|
||||
63
src/containers/login/sub-domain.tsx
Normal file
63
src/containers/login/sub-domain.tsx
Normal 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;
|
||||
24
src/containers/login/utils.ts
Normal file
24
src/containers/login/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -64,6 +64,9 @@ fieldset {
|
||||
> button {
|
||||
width: 100%;
|
||||
}
|
||||
> a {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.msg {
|
||||
width: 100%;
|
||||
@@ -287,3 +290,7 @@ fieldset {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bucket_tag {
|
||||
@extend .lcr;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user