Compare commits

...

36 Commits

Author SHA1 Message Date
Hoan Luu Huu
c33eb46ce0 soundhound speech credential support audio endpoint (#582)
* soubound speech credential support audio endpoint

* wip
2025-11-28 21:48:13 -05:00
Hoan Luu Huu
f003c158dc fix outbound call routing race condition show default lcr route set that user cannot delete (#577) 2025-11-18 07:52:28 -05:00
Sam Machin
b1ddaf230d require IP auth trunk to have either inbound or outbound carrier (#579)
also cleaned up wordig to be consistent `IP Trunk` not `Static IP Whitelist`
2025-11-10 10:29:45 -05:00
Hoan Luu Huu
0260b1ec8b Inbound and outbound sipgateway can be duplicated (#576) 2025-11-08 08:49:00 -05:00
Anton Voylenko
1c1f97f045 chore: bump node version (#575) 2025-11-04 19:33:41 -05:00
Hoan Luu Huu
e6c5a18c87 fixed reg trunk validation cannot move tab and focus to missing fields (#574)
* fixed reg trunk validation cannot move tab and focus to missing fields

* fixed reg trunk validation cannot move tab and focus to missing fields

* wip
2025-10-27 07:21:39 -04:00
Hoan Luu Huu
19742ab67e fixed cannot saved auth trunk (#573) 2025-10-24 07:22:36 -04:00
Hoan Luu Huu
53d0c0b510 Carrier change for trunk type (#564)
* support carrier credential authentication

* wip

* wip

* wip

* wip

* wip

* change trunk type to selector

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip
2025-10-21 06:49:25 -04:00
Hoan Luu Huu
7a0eb71bae support gladia stt (#572) 2025-10-20 04:47:55 -04:00
Hoan Luu Huu
6aae8d9930 support soundhound stt (#567)
* support houndify stt

* wip

* wip
2025-10-14 00:52:06 -04:00
Hoan Luu Huu
a70a1bf614 support elevenlabs different endpoint (#571)
* support elevenlabs different endpoint

* wip
2025-10-09 08:20:39 -04:00
Dave Horton
975a787f1e review Preview now that Flux is GA (#570) 2025-10-04 20:12:15 -04:00
Hoan Luu Huu
46e220f28b support deepgram flux (#569)
* support deepgram flux

* wip
2025-10-03 10:10:13 -04:00
Hoan Luu Huu
6836a99635 add special field for Carriers in env vars (#561)
* add special field for Carriers in env vars

* wip

* wip
2025-09-05 08:04:24 -04:00
Hoan Luu Huu
f7f4a2e7b1 cannot delete carrier because of undefined lcrs list (#563) 2025-09-01 08:12:31 -04:00
Hoan Luu Huu
f1f8a7d808 support resemble tts (#559)
* support resemble tts

* wip
2025-08-13 08:17:18 -04:00
sathish kumar pasham
9dd9cf867a 556 resolve security vulnerabilities by upgrading the vite library (#557) 2025-08-06 09:30:22 -04:00
Hoan Luu Huu
a372c09bc6 support deepgram EU-hosted STT (#555)
* support deepgram EU-hosted STT

* wip

* fix review comment

* wip

* wip
2025-08-04 07:24:33 -04:00
Hoan Luu Huu
031e5e923e support deepgram river (#547) 2025-07-29 13:54:22 -04:00
Hoan Luu Huu
e02904f7f3 Draw STT latency to recording player by using stt metrics from opentelemetry (#551)
* support showing stt latency from otel stt.latency_ms

* wip
2025-07-29 09:57:35 -04:00
Dave Horton
7eaf25d13f bump version 2025-07-15 11:42:26 -04:00
Hoan Luu Huu
6e4d663337 fixed deprecated api when migrating to sass 3 (#549) 2025-07-15 08:27:02 -04:00
sathish kumar pasham
c0a40dd784 resolve security vulnerabilities (#546) 2025-07-09 14:57:36 -04:00
Hoan Luu Huu
536bf0f471 support assemblyai v3 (#540)
* support assemblyai v3

* wip
2025-07-01 15:48:17 -04:00
Sam Machin
aaf1ede5c2 Update form.tsx (#545) 2025-07-01 07:57:05 -04:00
Hoan Luu Huu
24d646f705 support inworld tts (#537)
* support inworld tts

* wip
2025-06-27 07:13:51 -04:00
Hoan Luu Huu
c648afcb1a support mod cartesia transcribe (#536) 2025-06-17 20:53:45 +02:00
Hoan Luu Huu
4eca59d9bd fix regression bug: new app does not save tts voice by default (#535) 2025-06-06 14:50:34 +02:00
Hoan Luu Huu
4a293ae7da appEnvs should support enum dropdown (#532) 2025-06-02 07:41:18 -04:00
Hoan Luu Huu
03e52e3dc5 fixed Cannot delete Carrier, show message that there is link to LCR (#533)
* fixed Cannot delete Carrier, show message that there is link to LCR

* wip
2025-06-02 07:14:01 -04:00
Hoan Luu Huu
9ab592a898 fixed admin filter phone number by SP (#531) 2025-05-30 07:24:31 -04:00
Hoan Luu Huu
1723326890 fix app crash when create new speech credential (#530)
* fix app crash when create new speech credential

* fix app crash when create new speech credential
2025-05-29 08:31:41 -04:00
Hoan Luu Huu
504825d699 fix app envs does not take default value and filepicker is required even value is available (#529) 2025-05-28 19:58:21 -04:00
Hoan Luu Huu
e65d9b9db6 some S3 compatible storage systems have a region parameter (#524)
* some S3 compatible storage systems have a region parameter

* wip

* wip

* replace current toastMethod by new toastProvider

* wip

* fix failing testcase

* wip
2025-05-28 10:03:39 -04:00
Hoan Luu Huu
10818493bc support deepgram stt model (#528)
* support deepgram stt model

* wip

* wip
2025-05-28 08:01:20 -04:00
Hoan Luu Huu
844eec953c UI improvement. (#521)
* don't remove service provider sid and filteredAccountSid when logout

* support fetching applications with pagination

* applications wip

* support pagination for voip carriers

* wip

* support phone number pagination

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip
2025-05-28 07:28:52 -04:00
75 changed files with 3949 additions and 2359 deletions

2
.env
View File

@@ -1,4 +1,4 @@
#VITE_API_BASE_URL=http://127.0.0.1:3000/v1
# VITE_API_BASE_URL=http://127.0.0.1:3000/v1
#VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
## enables choosing units and lisenced account call limits

View File

@@ -1,4 +1,4 @@
FROM node:18.15-alpine3.16 as builder
FROM node:20-alpine AS builder
RUN apk update && apk add --no-cache python3 make g++
COPY . /opt/app
WORKDIR /opt/app/
@@ -6,7 +6,7 @@ RUN npm install
RUN npm run build
RUN npm prune
FROM node:18.14.1-alpine as webapp
FROM node:20-alpine AS webapp
RUN apk add curl
WORKDIR /opt/app
COPY . /opt/app

3113
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
{
"name": "jambonz-webapp",
"description": "A simple provisioning web app for jambonz",
"version": "0.9.4",
"version": "0.9.5",
"license": "MIT",
"type": "module",
"engines": {
"node": ">=14.18"
"node": ">=18"
},
"contributors": [
{
@@ -41,7 +41,7 @@
"deploy": "npm i && npm run build && npm run pm2"
},
"dependencies": {
"@jambonz/ui-kit": "^0.0.21",
"@jambonz/ui-kit": "^0.0.22",
"@stripe/react-stripe-js": "^2.6.2",
"@stripe/stripe-js": "^3.2.0",
"dayjs": "^1.11.10",
@@ -71,16 +71,16 @@
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"express": "^4.19.2",
"express": "^5.1.0",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"nanoid": "^5.0.7",
"lint-staged": "^16.1.2",
"nanoid": "^5.1.5",
"prettier": "^3.2.5",
"sass": "^1.74.1",
"serve": "^14.2.1",
"sass": "^1.89.2",
"serve": "^14.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.4.4",
"vite": "^5.2.8"
"vite": "^6.4.1"
},
"lint-staged": {
"*.{ts,tsx}": "eslint --max-warnings=0",

View File

@@ -3,6 +3,7 @@ import type {
Currency,
ElevenLabsOptions,
GoogleCustomVoice,
InworldOptions,
LimitField,
LimitUnitOption,
PasswordSettings,
@@ -92,10 +93,6 @@ export const DISABLE_ADDITIONAL_SPEECH_VENDORS: boolean =
export const AWS_REGION: string =
window.JAMBONZ?.AWS_REGION || import.meta.env.VITE_APP_AWS_REGION;
export const ENABLE_PHONE_NUMBER_LAZY_LOAD: boolean =
window.JAMBONZ?.ENABLE_PHONE_NUMBER_LAZY_LOAD === "true" ||
JSON.parse(import.meta.env.VITE_APP_ENABLE_PHONE_NUMBER_LAZY_LOAD || "false");
export const DEFAULT_SERVICE_PROVIDER_SID: string =
window.JAMBONZ?.DEFAULT_SERVICE_PROVIDER_SID ||
import.meta.env.VITE_APP_DEFAULT_SERVICE_PROVIDER_SID;
@@ -134,7 +131,7 @@ export const DEFAULT_WEBHOOK: WebHook = {
};
/** Default SIP/SMPP Gateways */
export const DEFAULT_SIP_GATEWAY: SipGateway = {
export const DEFAULT_SIP_INBOUND_GATEWAY: SipGateway = {
voip_carrier_sid: "",
ipv4: "",
port: 5060,
@@ -248,6 +245,14 @@ export const VERBIO_STT_MODELS = [
export const DEFAULT_VERBIO_MODEL = "V1";
// ASSEMBLYAI
export const ASSEMBLYAI_STT_VERSIONS = [
{ name: "V2", value: "v2" },
{ name: "V3", value: "v3" },
];
export const DEFAULT_ASSEMBLYAI_STT_VERSION = "v2";
export const ADDITIONAL_SPEECH_VENDORS: Lowercase<Vendor>[] = ["speechmatics"];
// Google Custom Voice reported usage options
@@ -281,6 +286,14 @@ export const DEFAULT_RIMELABS_OPTIONS: Partial<RimelabsOptions> = {
reduceLatency: true,
};
export const DEFAULT_INWORLD_OPTIONS: Partial<InworldOptions> = {
audioConfig: {
pitch: 0.0,
speakingRate: 1.0,
},
temperature: 0.8,
};
// PlayHT options
export const DEFAULT_PLAYHT_OPTIONS: Partial<PlayHTOptions> = {
quality: "medium",
@@ -335,6 +348,12 @@ export const DTMF_TYPE_SELECTION: SelectorOptions[] = [
{ name: "Tones", value: "tones" },
];
export const TRUNK_TYPE_SELECTION: SelectorOptions[] = [
{ name: "IP Trunk", value: "static_ip" },
{ name: "Auth Trunk", value: "auth" },
{ name: "Registration Trunk", value: "reg" },
];
/** Available webhook methods */
export const WEBHOOK_METHODS: WebhookOption[] = [
{
@@ -405,6 +424,11 @@ export const CurrencySymbol: Currency = {
usd: "$",
};
export const DEEPGRAM_STT_ENPOINT = [
{ name: "US (Default)", value: "" },
{ name: "EU-hosted", value: "api.eu.deepgram.com" },
];
/** User scope values values */
export const USER_ADMIN = "admin";
export const USER_SP = "service_provider";

View File

@@ -97,6 +97,8 @@ import type {
SpeechSupportedLanguagesAndVoices,
AppEnv,
PhoneNumberQuery,
ApplicationQuery,
VoipCarrierQuery,
} from "./types";
import { Availability, StatusCodes } from "./types";
import { JaegerRoot } from "./jaeger-types";
@@ -821,6 +823,28 @@ export const getAppEnvSchema = (url: string) => {
return getFetch<AppEnv>(`${API_APP_ENV}?url=${url}`);
};
export const getApplications = (
sid: string,
query: Partial<ApplicationQuery>,
) => {
const qryStr = getQuery<Partial<ApplicationQuery>>(query);
return getFetch<PagedResponse<Application>>(
`${API_ACCOUNTS}/${sid}/Applications?${qryStr}`,
);
};
export const getSPVoipCarriers = (
sid: string,
query: Partial<VoipCarrierQuery>,
) => {
const qryStr = getQuery<Partial<VoipCarrierQuery>>(query);
return getFetch<PagedResponse<Carrier>>(
`${API_SERVICE_PROVIDERS}/${sid}/VoipCarriers?${qryStr}`,
);
};
/** Wrappers for APIs that can have a mock dev server response */
export const getMe = () => {
@@ -903,7 +927,7 @@ export const getPrice = () => {
export const getPhoneNumbers = (query: Partial<PhoneNumberQuery>) => {
const qryStr = getQuery<Partial<PhoneNumberQuery>>(query);
return getFetch<PhoneNumber[]>(`${API_PHONE_NUMBERS}?${qryStr}`);
return getFetch<PagedResponse<PhoneNumber>>(`${API_PHONE_NUMBERS}?${qryStr}`);
};
export const getSpeechSupportedLanguagesAndVoices = (

View File

@@ -1,4 +1,10 @@
import type { Language, Model, Vendor, VoiceLanguage } from "src/vendor/types";
import type {
JambonzResourceOptions,
Language,
Model,
Vendor,
VoiceLanguage,
} from "src/vendor/types";
/** Simple types */
@@ -413,6 +419,7 @@ export interface SpeechCredential {
custom_stt_endpoint: null | string;
client_id: null | string;
client_secret: null | string;
client_key: null | string;
secret: null | string;
nuance_tts_uri: null | string;
nuance_stt_uri: null | string;
@@ -429,8 +436,10 @@ export interface SpeechCredential {
label: null | string;
cobalt_server_uri: null | string;
model_id: null | string;
stt_model_id: null | string;
voice_engine: null | string;
engine_version: null | string;
service_version: null | string;
model: null | string;
options: null | string;
deepgram_stt_uri: null | string;
@@ -438,6 +447,10 @@ export interface SpeechCredential {
deepgram_stt_use_tls: number;
speechmatics_stt_uri: null | string;
playht_tts_uri: null | string;
resemble_tts_uri: null | string;
resemble_tts_use_tls: number;
api_uri: null | string;
houndify_server_uri: null | string;
}
export interface Alert {
@@ -457,6 +470,8 @@ export interface CarrierRegisterStatus {
export type DtmfType = "rfc2833" | "tones" | "info";
export type TrunkType = "static_ip" | "auth" | "reg";
export interface Carrier {
voip_carrier_sid: string;
name: string;
@@ -485,6 +500,7 @@ export interface Carrier {
register_status: CarrierRegisterStatus;
dtmf_type: DtmfType;
outbound_sip_proxy: string | null;
trunk_type: TrunkType;
}
export interface PredefinedCarrier extends Carrier {
@@ -557,12 +573,14 @@ export interface Client {
export interface PageQuery {
page: number;
page_size?: number;
count: number;
start?: string;
days?: number;
}
export interface PhoneNumberQuery extends PageQuery {
service_provider_sid?: string;
account_sid?: string;
filter?: string;
}
@@ -572,6 +590,15 @@ export interface CallQuery extends PageQuery {
answered?: string;
}
export interface ApplicationQuery extends PageQuery {
name?: string;
}
export interface VoipCarrierQuery extends PageQuery {
name?: string;
account_sid?: string;
}
export interface GoogleCustomVoicesQuery {
speech_credential_sid?: string;
label?: string;
@@ -769,6 +796,16 @@ export interface RimelabsOptions {
reduceLatency: boolean;
}
export interface InworldOptions {
audioConfig: {
bitRate?: number;
sampleRateHertz?: number;
pitch?: number;
speakingRate?: number;
};
temperature?: number;
}
export type CartesiaEmotions =
| "anger:lowest"
| "anger:low"
@@ -799,6 +836,9 @@ export interface AppEnvProperty {
default?: string | number | boolean;
obscure?: boolean;
uiHint?: "input" | "textarea" | "filepicker";
enum?: string[];
jambonzResource?: "carriers";
jambonzResourceOptions?: JambonzResourceOptions[];
}
export interface AppEnv {

View File

@@ -1,7 +1,7 @@
import React from "react";
import { Icons } from "src/components/icons";
import { toastError, toastSuccess } from "src/store";
import { useToast } from "../toast/toast-provider";
type ClipBoardProps = {
id?: string;
@@ -13,6 +13,7 @@ type ClipBoardProps = {
const hasClipboard = typeof navigator.clipboard !== "undefined";
export const ClipBoard = ({ text, id = "", name = "" }: ClipBoardProps) => {
const { toastSuccess, toastError } = useToast();
const handleClick = () => {
navigator.clipboard
.writeText(text)

View File

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

View File

@@ -28,7 +28,6 @@
}
@mixin typeahead-input {
@include ui-mixins.m();
appearance: none;
padding: ui-vars.$px01 ui-vars.$px02;
border-radius: ui-vars.$px01;
@@ -37,6 +36,7 @@
max-width: vars.$widthtypeaheadinput;
transition: border-color 0.2s ease;
font-family: inherit;
@include ui-mixins.m();
&:focus {
border-color: ui-vars.$dark;
@@ -84,7 +84,6 @@
}
@mixin typeahead-dropdown {
@include ui-mixins.m();
position: absolute;
top: 100%;
left: 0;
@@ -93,6 +92,7 @@
border: 1px solid ui-vars.$dark;
max-height: 200px;
overflow-y: auto;
@include ui-mixins.m();
}
@mixin typeahead-option {
@@ -126,8 +126,8 @@
width: 100%;
input {
@include typeahead-input();
width: 100%;
@include typeahead-input();
}
span {
@@ -135,8 +135,8 @@
}
.typeahead-dropdown {
@include typeahead-dropdown();
z-index: 1000;
@include typeahead-dropdown();
}
.typeahead-option {
@@ -149,10 +149,10 @@
width: auto;
input {
@include typeahead-input();
height: 34px;
min-width: 370px;
font-size: var(--mxs-size);
@include typeahead-input();
}
span {
@@ -160,13 +160,13 @@
}
.typeahead-dropdown {
@include typeahead-dropdown();
width: 100%;
@include typeahead-dropdown();
}
.typeahead-option {
@include typeahead-option();
font-size: var(--mxs-size);
@include typeahead-option();
}
.pointerevents {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { useParams } from "react-router-dom";
import { ApiKeys } from "src/containers/internal/api-keys";
import { useApiData } from "src/api";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { AccountForm } from "./form";
import type { Account, Application, Limit, TtsCache } from "src/api/types";
@@ -14,8 +14,10 @@ import {
} from "src/router/routes";
import { useScopedRedirect } from "src/utils";
import { Scope } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
export const EditAccount = () => {
const { toastError } = useToast();
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Account>(

View File

@@ -2,7 +2,7 @@ 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 { useSelectState } from "src/store";
import {
putAccount,
postAccount,
@@ -75,6 +75,7 @@ import { EditBoard } from "src/components/editboard";
import { ModalLoader } from "src/components/modal";
import { useAuth } from "src/router/auth";
import { Scope } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
type AccountFormProps = {
apps?: Application[];
@@ -89,6 +90,7 @@ export const AccountForm = ({
account,
ttsCache,
}: AccountFormProps) => {
const { toastError, toastSuccess } = useToast();
const params = useParams();
const navigate = useNavigate();
const user = useSelectState("user");
@@ -289,6 +291,7 @@ export const AccountForm = ({
endpoint: endpoint,
access_key_id: bucketAccessKeyId,
secret_access_key: bucketSecretAccessKey,
...(bucketRegion && { region: bucketRegion }),
}),
};
@@ -437,6 +440,9 @@ export const AccountForm = ({
access_key_id: bucketAccessKeyId || null,
secret_access_key: bucketSecretAccessKey || null,
...(hasLength(bucketTags) && { tags: bucketTags }),
...(bucketRegion && {
region: bucketRegion,
}),
},
}),
...(!bucketCredentialChecked && {
@@ -550,6 +556,10 @@ export const AccountForm = ({
setBucketRegion(tmpBucketRegion);
} else if (account.data.bucket_credential?.region) {
setBucketRegion(account.data.bucket_credential?.region);
} else if (
account.data.bucket_credential?.vendor === BUCKET_VENDOR_S3_COMPATIBLE
) {
setBucketRegion("");
}
if (tmpAzureConnectionString) {
@@ -583,9 +593,7 @@ export const AccountForm = ({
JSON.parse(account.data.bucket_credential?.service_key),
);
}
setInitialCheckRecordAllCall(
hasValue(bucketVendor) && bucketVendor.length !== 0,
);
setInitialCheckRecordAllCall(hasValue(account.data.bucket_credential));
}
}, [account]);
@@ -1102,6 +1110,18 @@ export const AccountForm = ({
onChange={(e) => {
setBucketVendor(e.target.value);
setTmpBucketVendor(e.target.value);
if (
e.target.value === BUCKET_VENDOR_AWS &&
!regions?.aws.find((r) => r.value === bucketRegion)
) {
setBucketRegion("us-east-1");
setTmpBucketRegion("us-east-1");
} else if (
e.target.value === BUCKET_VENDOR_S3_COMPATIBLE
) {
setBucketRegion("");
setTmpBucketRegion("");
}
}}
/>
</div>
@@ -1122,6 +1142,17 @@ export const AccountForm = ({
setTmpEndpoint(e.target.value);
}}
/>
<label htmlFor="endpoint">Region (Optional)</label>
<input
id="aws_compatible_region"
type="text"
name="aws_compatible_region"
value={bucketRegion}
onChange={(e) => {
setBucketRegion(e.target.value);
setTmpBucketRegion(e.target.value);
}}
/>
</>
)}
<label htmlFor="bucket_name">

View File

@@ -6,7 +6,7 @@ import { useServiceProviderData, deleteAccount } from "src/api";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import { Section, Icons, Spinner, SearchFilter } from "src/components";
import { DeleteAccount } from "./delete";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
hasLength,
hasValue,
@@ -17,8 +17,10 @@ import { USER_ACCOUNT } from "src/api/constants";
import { Scope } from "src/store/types";
import type { Account } from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
export const Accounts = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [accounts, refetch] = useServiceProviderData<Account[]>("Accounts");
const [account, setAccount] = useState<Account | null>(null);

View File

@@ -10,11 +10,13 @@ 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 { useSelectState } from "src/store";
import { PaymentMethod } from "@stripe/stripe-js";
import { ModalLoader } from "src/components/modal";
import { useToast } from "src/components/toast/toast-provider";
export const ManagePaymentForm = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const stripe = useStripe();
const elements = useElements();

View File

@@ -19,10 +19,11 @@ import {
useStripe,
} from "@stripe/react-stripe-js";
import { PaymentMethod } from "@stripe/stripe-js";
import { toastError, toastSuccess } from "src/store";
import { ModalLoader } from "src/components/modal";
import { useToast } from "src/components/toast/toast-provider";
const SubscriptionForm = () => {
const { toastError, toastSuccess } = useToast();
const [userData] = useApiData<CurrentUserData>("Users/me");
const [priceInfo] = useApiData<PriceInfo[]>("/Prices");
const [userStripeInfo] = useApiData<StripeCustomerId>("/StripeCustomerId");

View File

@@ -8,7 +8,7 @@ import {
PER_PAGE_SELECTION,
USER_ACCOUNT,
} from "src/api/constants";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { hasLength, hasValue } from "src/utils";
import {
AccountFilter,
@@ -27,8 +27,10 @@ import {
setLocation,
} from "src/store/localStore";
import AlertDetailItem from "./alert-detail-item";
import { useToast } from "src/components/toast/toast-provider";
export const Alerts = () => {
const { toastError } = useToast();
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
@@ -68,10 +70,10 @@ export const Alerts = () => {
};
useMemo(() => {
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
if (getQueryFilter()) {
const [date] = getQueryFilter().split("/");
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
setDateFilter(date);
}
}, [accountSid]);

View File

@@ -3,15 +3,17 @@ import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { ApplicationForm } from "./form";
import type { Application } from "src/api/types";
import { useScopedRedirect } from "src/utils/use-scoped-redirect";
import { Scope } from "src/store/types";
import { ROUTE_INTERNAL_APPLICATIONS } from "src/router/routes";
import { useToast } from "src/components/toast/toast-provider";
export const EditApplication = () => {
const { toastError } = useToast();
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Application>(

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
import { Link, useNavigate } from "react-router-dom";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { ClipBoard, Section, Tooltip } from "src/components";
import {
Selector,
@@ -25,6 +25,7 @@ import {
useServiceProviderData,
useApiData,
getAppEnvSchema,
getSPVoipCarriers,
} from "src/api";
import {
ROUTE_INTERNAL_ACCOUNTS,
@@ -53,16 +54,23 @@ import type {
AppEnv,
} from "src/api/types";
import { MSG_REQUIRED_FIELDS, MSG_WEBHOOK_FIELDS } from "src/constants";
import { hasLength, isUserAccountScope, useRedirect } from "src/utils";
import {
hasLength,
hasValue,
isUserAccountScope,
useRedirect,
} from "src/utils";
import { setAccountFilter, setLocation } from "src/store/localStore";
import SpeechProviderSelection from "./speech-selection";
import ObscureInput from "src/components/obscure-input";
import { useToast } from "src/components/toast/toast-provider";
type ApplicationFormProps = {
application?: UseApiDataMap<Application>;
};
export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
@@ -581,6 +589,51 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
setFallbackSpeechRecognizerLabel(tmp);
};
const fetchAppEnvJambonzResources = async (appEnv: AppEnv) => {
if (appEnv) {
const promises = Object.entries(appEnv).map(async ([key, value]) => {
const { jambonzResource } = value;
switch (jambonzResource) {
case "carriers":
const carriers = await getSPVoipCarriers(
currentServiceProvider?.service_provider_sid || "",
{
page: 1,
page_size: 10000,
...(user?.account_sid && {
account_sid: user.account_sid,
}),
},
);
if (carriers.json.total) {
return {
key,
jambonzResourceOptions: carriers.json.data.map((carrier) => ({
name: carrier.name,
value: carrier.name,
})),
};
}
break;
default:
break;
}
return { key, jambonzResourceOptions: null };
});
const results = await Promise.all(promises);
// Merge the results back into appEnv
results.forEach(({ key, jambonzResourceOptions }) => {
if (jambonzResourceOptions) {
appEnv[key].jambonzResourceOptions = jambonzResourceOptions;
}
});
}
return appEnv;
};
useEffect(() => {
if (callWebhook && callWebhook.url) {
// Clear any existing timeout to prevent multiple requests
@@ -592,7 +645,26 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
appEnvTimeoutRef.current = setTimeout(() => {
getAppEnvSchema(callWebhook.url)
.then(({ json }) => {
setAppEnv(json);
// fetch app env jambonz_resource
fetchAppEnvJambonzResources(json).then((updatedEnv) => {
setAppEnv(updatedEnv);
const defaultEnvVars = Object.keys(updatedEnv).reduce(
(acc, key) => {
const value = updatedEnv[key];
if (value?.default) {
return { ...acc, [key]: value.default };
}
return acc;
},
{},
);
setEnvVars((prev) => ({
...defaultEnvVars,
...(prev || {}),
}));
});
// Default value
})
.catch((error) => {
setMessage(error.msg);
@@ -826,7 +898,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
? String(defaultValue)
: "",
onChange: (
e: React.ChangeEvent<HTMLInputElement>,
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement
>,
) => {
// Convert to proper type based on schema
let newValue;
@@ -851,6 +925,15 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
type: isNumber ? "number" : "text",
};
const isDropdown =
(webhook.webhookEnv![key].type === "string" &&
(webhook.webhookEnv![key].enum?.length ||
0) > 0) ||
hasLength(
webhook.webhookEnv![key]
.jambonzResourceOptions,
);
const textAreaSpecificProps = {
rows: 6,
cols: 61,
@@ -861,6 +944,23 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
.obscure
? ObscureInput
: webhook.webhookEnv![key].uiHint || "input";
if (isDropdown) {
const options =
webhook.webhookEnv![key]
.jambonzResourceOptions ||
webhook.webhookEnv![key].enum!.map(
(option) => ({
name: option,
value: option,
}),
);
return (
<Selector
{...commonProps}
options={options}
/>
);
}
if (componentType === "filepicker") {
return (
<>
@@ -884,7 +984,8 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
}}
placeholder="Choose a file"
required={
webhook.webhookEnv![key].required
webhook.webhookEnv![key].required &&
!hasValue(envVars?.[key])
}
/>
{React.createElement("textarea", {
@@ -922,6 +1023,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
serviceProviderSid={
currentServiceProvider?.service_provider_sid || ""
}
application_speech_synthesis_voice={
application?.data?.speech_synthesis_voice
}
accountSid={accountSid}
credentials={credentials}
ttsVendor={[synthVendor, setSynthVendor]}
@@ -952,6 +1056,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
currentServiceProvider?.service_provider_sid || ""
}
accountSid={accountSid}
application_speech_synthesis_voice={
application?.data?.fallback_speech_synthesis_voice
}
credentials={credentials}
ttsVendor={[
fallbackSpeechSynthsisVendor,

View File

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

View File

@@ -10,7 +10,8 @@ import {
} from "src/api/types";
import { Selector } from "src/components/forms";
import { SelectorOption } from "src/components/forms/selector";
import { toastError, useSelectState } from "src/store";
import { useToast } from "src/components/toast/toast-provider";
import { useSelectState } from "src/store";
import { hasLength } from "src/utils";
import {
ELEVENLABS_LANG_EN,
@@ -34,6 +35,10 @@ import {
VENDOR_VOXIST,
VENDOR_RIMELABS,
VENDOR_OPENAI,
VENDOR_INWORLD,
VENDOR_DEEPGRAM_FLUX,
VENDOR_RESEMBLE,
VENDOR_HOUNDIFY,
} from "src/vendor";
import {
LabelOptions,
@@ -44,6 +49,7 @@ import {
type SpeechProviderSelectionProbs = {
accountSid: string;
serviceProviderSid: string;
application_speech_synthesis_voice: string | null | undefined;
credentials: SpeechCredential[] | undefined;
ttsVendor: [
keyof SynthesisVendors,
@@ -67,6 +73,7 @@ type SpeechProviderSelectionProbs = {
export const SpeechProviderSelection = ({
accountSid,
serviceProviderSid,
application_speech_synthesis_voice,
credentials,
ttsVendor: [synthVendor, setSynthVendor],
ttsVendorOptions,
@@ -80,6 +87,7 @@ export const SpeechProviderSelection = ({
sttLabelOptions,
sttLabel: [recogLabel, setRecogLabel],
}: SpeechProviderSelectionProbs) => {
const { toastError } = useToast();
const user = useSelectState("user");
const [
synthesisSupportedLanguagesAndVoices,
@@ -135,7 +143,12 @@ export const SpeechProviderSelection = ({
ttsEffectTimer.current = setTimeout(() => {
configSynthesis();
}, 200);
}, [synthVendor, synthLabel, serviceProviderSid]);
}, [
synthVendor,
synthLabel,
serviceProviderSid,
application_speech_synthesis_voice,
]);
// Get Recognizer languages and voices
useEffect(() => {
@@ -242,9 +255,14 @@ export const SpeechProviderSelection = ({
// Extract model
if (json.models && json.models.length) {
setSynthesisModelOptions(json.models);
if (synthVendor === VENDOR_DEEPGRAM) {
if (
synthVendor === VENDOR_DEEPGRAM &&
(!application_speech_synthesis_voice ||
!json.models.some(
(m) => m.value === application_speech_synthesis_voice,
))
) {
setSynthVoice(json.models[0].value);
return;
}
}
@@ -298,6 +316,15 @@ export const SpeechProviderSelection = ({
updateTtsVoice(newLang!.value, newLang!.voices[0].value);
return;
}
if (synthVendor === VENDOR_INWORLD) {
let newLang = json.tts.find((lang) => lang.value === "en");
// If the new language doesn't map then default to the first one
if (!newLang) {
newLang = json.tts[0];
}
updateTtsVoice(newLang!.value, newLang!.voices[0].value);
return;
}
/** Google and AWS have different language lists */
/** If the new language doesn't map then default to "en-US" */
let newLang = json.tts.find((lang) => lang.value === synthLang);
@@ -344,6 +371,9 @@ export const SpeechProviderSelection = ({
};
const configRecognizer = () => {
if (recogVendor === VENDOR_DEEPGRAM_FLUX) {
return;
}
getSpeechSupportedLanguagesAndVoices(
serviceProviderSid,
recogVendor,
@@ -387,6 +417,7 @@ export const SpeechProviderSelection = ({
toastError(error.msg);
});
};
return (
<>
<fieldset>
@@ -403,6 +434,8 @@ export const SpeechProviderSelection = ({
vendor.value !== VENDOR_SPEECHMATICS &&
vendor.value !== VENDOR_CUSTOM &&
vendor.value !== VENDOR_OPENAI &&
vendor.value !== VENDOR_DEEPGRAM_FLUX &&
vendor.value !== VENDOR_HOUNDIFY &&
vendor.value !== VENDOR_COBALT,
)}
onChange={(e) => {
@@ -557,6 +590,7 @@ export const SpeechProviderSelection = ({
vendor.value != VENDOR_WELLSAID &&
vendor.value != VENDOR_ELEVENLABS &&
vendor.value != VENDOR_WHISPER &&
vendor.value !== VENDOR_RESEMBLE &&
vendor.value !== VENDOR_CUSTOM,
)}
onChange={(e) => {
@@ -584,6 +618,7 @@ export const SpeechProviderSelection = ({
)}
{recogVendor &&
!recogVendor.toString().startsWith(VENDOR_CUSTOM) &&
recogVendor !== VENDOR_DEEPGRAM_FLUX &&
recogLang && (
<>
<label htmlFor="recognizer_lang">Language</label>

View File

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

View File

@@ -3,15 +3,17 @@ import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { CarrierForm } from "./form";
import { Carrier, SipGateway, SmppGateway } from "src/api/types";
import { useScopedRedirect } from "src/utils/use-scoped-redirect";
import { ROUTE_INTERNAL_CARRIERS } from "src/router/routes";
import { Scope } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
export const EditCarrier = () => {
const { toastError } = useToast();
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Carrier>(

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,16 @@
import React, { useState, useMemo, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Link } from "react-router-dom";
import { Button, H1, Icon, M } from "@jambonz/ui-kit";
import { Button, ButtonGroup, H1, Icon, M, MS } from "@jambonz/ui-kit";
import {
deleteCarrier,
deleteSipGateway,
deleteSmppGateway,
getFetch,
getSPVoipCarriers,
useApiData,
useServiceProviderData,
} from "src/api";
import { toastSuccess, toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { ROUTE_INTERNAL_CARRIERS } from "src/router/routes";
import {
AccountFilter,
@@ -17,20 +18,18 @@ import {
Section,
Spinner,
SearchFilter,
Pagination,
SelectFilter,
} from "src/components";
import { ScopedAccess } from "src/components/scoped-access";
import { Gateways } from "./gateways";
import {
isUserAccountScope,
hasLength,
hasValue,
useFilteredResults,
} from "src/utils";
import { isUserAccountScope, hasLength, hasValue } from "src/utils";
import {
API_SIP_GATEWAY,
API_SMPP_GATEWAY,
CARRIER_REG_OK,
ENABLE_HOSTED_SYSTEM,
PER_PAGE_SELECTION,
USER_ACCOUNT,
} from "src/api/constants";
import { DeleteCarrier } from "./delete";
@@ -44,37 +43,62 @@ import type {
} from "src/api/types";
import { Scope } from "src/store/types";
import { getAccountFilter, setLocation } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
export const Carriers = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [userData] = useApiData<CurrentUserData>("Users/me");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [apiUrl, setApiUrl] = useState("");
const [carrier, setCarrier] = useState<Carrier | null>(null);
const [carriers, refetch] = useApiData<Carrier[]>(apiUrl);
const [carriers, setCarriers] = useState<Carrier[] | null>(null);
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
const [filter, setFilter] = useState("");
const carriersFiltered = useMemo(() => {
setAccountSid(getAccountFilter());
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
const [carriersTotal, setCarriersTotal] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [perPageFilter, setPerPageFilter] = useState("25");
const [maxPageNumber, setMaxPageNumber] = useState(1);
// Add a ref to track previous values
const prevValuesRef = useRef({
serviceProviderId: "",
accountSid: "",
filter: "",
pageNumber: 1,
perPageFilter: "25",
});
const fetchCarriers = (resetPage = false) => {
if (!currentServiceProvider) return;
setCarriers(null);
// Calculate the correct page to use
const currentPage = resetPage ? 1 : pageNumber;
// If we're resetting the page, also update the state
if (resetPage && pageNumber !== 1) {
setPageNumber(1);
}
return carriers
? carriers.filter((carrier) =>
accountSid
? carrier.account_sid === accountSid
: carrier.account_sid === null,
)
: [];
}, [accountSid, carrier, carriers]);
const filteredCarriers = useFilteredResults<Carrier>(
filter,
carriersFiltered,
);
getSPVoipCarriers(currentServiceProvider.service_provider_sid, {
page: currentPage,
page_size: Number(perPageFilter),
...(filter && { name: filter }),
...(accountSid && { account_sid: accountSid }),
})
.then(({ json }) => {
setCarriers(json.data);
setCarriersTotal(json.total);
setMaxPageNumber(Math.ceil(json.total / Number(perPageFilter)));
})
.catch((error) => {
setCarriers([]);
toastError(error.msg);
});
};
const handleDelete = () => {
if (carrier) {
@@ -113,7 +137,7 @@ export const Carriers = () => {
);
});
setCarrier(null);
refetch();
fetchCarriers(false);
toastSuccess(
<>
Deleted Carrier <strong>{carrier.name}</strong>
@@ -126,14 +150,45 @@ export const Carriers = () => {
}
};
// Initial account setup
useEffect(() => {
setLocation();
if (currentServiceProvider) {
setApiUrl(
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`,
);
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
} else {
setAccountSid(getAccountFilter());
}
}, [user, currentServiceProvider, accountSid]);
setLocation();
}, [user, accounts]);
// Combined effect for all data fetching
useEffect(() => {
if (!currentServiceProvider) return;
const prevValues = prevValuesRef.current;
const currentSPId = currentServiceProvider.service_provider_sid;
// Determine if we should reset pagination
const isFilterOrProviderChange =
prevValues.serviceProviderId !== currentSPId ||
prevValues.accountSid !== accountSid ||
prevValues.filter !== filter;
const isPageSizeChange =
prevValues.perPageFilter !== perPageFilter &&
prevValues.perPageFilter !== "25"; // Skip initial render
// Update ref for next comparison
prevValuesRef.current = {
serviceProviderId: currentSPId,
accountSid,
filter,
pageNumber,
perPageFilter,
};
// Fetch data with page reset if filters changed
fetchCarriers(isFilterOrProviderChange || isPageSizeChange);
}, [currentServiceProvider, accountSid, filter, pageNumber, perPageFilter]);
return (
<>
@@ -159,6 +214,7 @@ export const Carriers = () => {
<SearchFilter
placeholder="Filter carriers"
filter={[filter, setFilter]}
delay={1000}
/>
<ScopedAccess user={user} scope={Scope.service_provider}>
<AccountFilter
@@ -169,12 +225,12 @@ export const Carriers = () => {
/>
</ScopedAccess>
</section>
<Section {...(hasLength(filteredCarriers) && { slim: true })}>
<Section {...(hasLength(carriers) && { slim: true })}>
<div className="list">
{!hasValue(carriers) && hasLength(accounts) ? (
<Spinner />
) : hasLength(filteredCarriers) ? (
filteredCarriers.map((carrier) => (
) : hasLength(carriers) ? (
carriers.map((carrier) => (
<div className="item" key={carrier.voip_carrier_sid}>
<div className="item__info">
<div className="item__title">
@@ -274,6 +330,26 @@ export const Carriers = () => {
Add carrier
</Button>
</Section>
<footer>
<ButtonGroup>
<MS>
Total: {carriersTotal} record
{carriersTotal === 1 ? "" : "s"}
</MS>
{hasLength(carriers) && (
<Pagination
pageNumber={pageNumber}
setPageNumber={setPageNumber}
maxPageNumber={maxPageNumber}
/>
)}
<SelectFilter
id="page_filter"
filter={[perPageFilter, setPerPageFilter]}
options={PER_PAGE_SELECTION}
/>
</ButtonGroup>
</footer>
{carrier && (
<DeleteCarrier
carrier={carrier}

View File

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

View File

@@ -3,11 +3,12 @@ import React, { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useApiData } from "src/api";
import { Client } from "src/api/types";
import { toastError } from "src/store";
import ClientsForm from "./form";
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
import { useToast } from "src/components/toast/toast-provider";
export const ClientsEdit = () => {
const { toastError } = useToast();
const params = useParams();
const navigate = useNavigate();
const [data, refetch, error] = useApiData<Client>(

View File

@@ -13,16 +13,18 @@ import { Section, Tooltip } from "src/components";
import { AccountSelect, Message, Passwd } from "src/components/forms";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import ClientsDelete from "./delete";
import { hasValue } from "src/utils";
import { IMessage } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
type ClientsFormProps = {
client?: UseApiDataMap<Client>;
};
export const ClientsForm = ({ client }: ClientsFormProps) => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const navigate = useNavigate();

View File

@@ -12,13 +12,16 @@ import {
Spinner,
} from "src/components";
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { Scope } from "src/store/types";
import { hasLength, hasValue, useFilteredResults } from "src/utils";
import ClientsDelete from "./delete";
import { USER_ACCOUNT } from "src/api/constants";
import { useToast } from "src/components/toast/toast-provider";
import { getAccountFilter } from "src/store/localStore";
export const Clients = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [userData] = useApiData<CurrentUserData>("Users/me");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
@@ -32,6 +35,7 @@ export const Clients = () => {
const [client, setClient] = useState<Client | null>();
const tmpFilteredClients = useMemo(() => {
setAccountSid(getAccountFilter() || accountSid);
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
return clients;

View File

@@ -4,8 +4,8 @@ import Card from "./card";
import { hasLength } from "src/utils";
import update from "immutability-helper";
import { deleteLcrRoute } from "src/api";
import { toastError, toastSuccess } from "src/store";
import { SelectorOption } from "src/components/forms/selector";
import { useToast } from "src/components/toast/toast-provider";
type ContainerProps = {
lcrRoute: [LcrRoute[], React.Dispatch<React.SetStateAction<LcrRoute[]>>];
@@ -16,6 +16,7 @@ export const Container = ({
lcrRoute: [lcrRoutes, setLcrRoutes],
carrierSelectorOptions,
}: ContainerProps) => {
const { toastSuccess, toastError } = useToast();
const moveCard = (dragIndex: number, hoverIndex: number) => {
setLcrRoutes((prevCards) =>
update(prevCards, {

View File

@@ -2,12 +2,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Button, ButtonGroup, Icon, MS, MXS } from "@jambonz/ui-kit";
import { Icons, Section } from "src/components";
import {
toastError,
toastSuccess,
useDispatch,
useSelectState,
} from "src/store";
import { useDispatch, useSelectState } from "src/store";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import { setLocation } from "src/store/localStore";
import { AccountSelect, Message, Selector } from "src/components/forms";
@@ -35,6 +30,7 @@ import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import Container from "./container";
import { hasValue } from "src/utils";
import { useToast } from "src/components/toast/toast-provider";
type LcrFormProps = {
lcrDataMap?: UseApiDataMap<Lcr>;
@@ -56,6 +52,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
],
};
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
const dispatch = useDispatch();
@@ -70,9 +67,6 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
const [accountSid, setAccountSid] = useState("");
const [isActive, setIsActive] = useState(true);
const [lcrRoutes, setLcrRoutes] = useState<LcrRoute[]>([LCR_ROUTE_TEMPLATE]);
const [previousLcrRoutes, setPreviousLcrRoutes] = useState<LcrRoute[]>([
LCR_ROUTE_TEMPLATE,
]);
const [previouseLcr, setPreviousLcr] = useState<Lcr | null>();
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [lcrForDelete, setLcrForDelete] = useState<Lcr | null>();
@@ -85,7 +79,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
setLocation();
if (currentServiceProvider) {
setApiUrl(
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`,
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers${accountSid ? `?account_sid=${accountSid}` : ""}`,
);
}
}, [user, currentServiceProvider, accountSid]);
@@ -95,16 +89,8 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
setAccountSid(user?.account_sid);
}
const carriersFiltered = carriers
? carriers.filter((carrier) =>
accountSid
? carrier.account_sid === accountSid
: carrier.account_sid === null,
)
: [];
const ret = carriersFiltered
? carriersFiltered.map((c: Carrier, i) => {
const ret = carriers
? carriers.map((c: Carrier, i) => {
if (i === 0) {
setDefaultCarrier(c.voip_carrier_sid);
}
@@ -126,45 +112,47 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
return ret;
}, [accountSid, carriers]);
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data !== previouseLcr) {
setLcrName(lcrDataMap.data.name || "");
setIsActive(lcrDataMap.data.is_active);
setPreviousLcr(lcrDataMap.data);
}
useEffect(() => {
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data !== previouseLcr) {
setLcrName(lcrDataMap.data.name || "");
setIsActive(lcrDataMap.data.is_active);
setPreviousLcr(lcrDataMap.data);
if (lcrDataMap.data.account_sid) {
setAccountSid(lcrDataMap.data.account_sid);
}
}
}, [lcrDataMap?.data, previouseLcr]);
useMemo(() => {
let default_lcr_route_sid = "";
if (
lcrRouteDataMap &&
lcrRouteDataMap.data &&
lcrRouteDataMap.data !== previousLcrRoutes
) {
setPreviousLcrRoutes(lcrRouteDataMap.data);
// Find default carrier
lcrRouteDataMap.data.forEach((lr) => {
lr.lcr_carrier_set_entries?.forEach((entry) => {
if (
entry.lcr_carrier_set_entry_sid ===
lcrDataMap?.data?.default_carrier_set_entry_sid
) {
// Only process when both lcrDataMap and lcrRouteDataMap are available
if (lcrRouteDataMap && lcrRouteDataMap.data && lcrDataMap?.data) {
const defaultCarrierSetEntrySid =
lcrDataMap.data.default_carrier_set_entry_sid;
// Find and store default route information
lcrRouteDataMap.data.forEach((route) => {
route.lcr_carrier_set_entries?.forEach((entry) => {
if (entry.lcr_carrier_set_entry_sid === defaultCarrierSetEntrySid) {
setDefaultLcrCarrier(entry.voip_carrier_sid || defaultCarrier);
setDefaultLcrCarrierSetEntrySid(
entry.lcr_carrier_set_entry_sid || null,
);
default_lcr_route_sid = entry.lcr_route_sid || "";
setDefaultLcrRoute(lr);
setDefaultLcrRoute(route);
}
});
});
}
if (lcrRouteDataMap && lcrRouteDataMap.data)
setLcrRoutes(
lcrRouteDataMap.data.filter(
(route) => route.lcr_route_sid !== default_lcr_route_sid,
),
);
}, [lcrRouteDataMap?.data]);
// Filter out routes that contain the default carrier set entry
const filteredRoutes = lcrRouteDataMap.data.filter((route) => {
return !route.lcr_carrier_set_entries?.some(
(entry) =>
entry.lcr_carrier_set_entry_sid === defaultCarrierSetEntrySid,
);
});
setLcrRoutes(filteredRoutes);
}
}, [lcrRouteDataMap?.data, lcrDataMap?.data]);
const addLcrRoutes = () => {
const newLcrRoute = LCR_ROUTE_TEMPLATE;

View File

@@ -14,7 +14,7 @@ import {
} from "src/components";
import { ScopedAccess } from "src/components/scoped-access";
import { ROUTE_INTERNAL_LEST_COST_ROUTING } from "src/router/routes";
import { toastSuccess, toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
// import { getAccountFilter, setLocation } from "src/store/localStore";
import { Scope } from "src/store/types";
import {
@@ -25,8 +25,10 @@ import {
} from "src/utils";
import { USER_ACCOUNT } from "src/api/constants";
import DeleteLcr from "./delete";
import { useToast } from "src/components/toast/toast-provider";
export const Lcrs = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
useScopedRedirect(
Scope.admin,

View File

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

View File

@@ -15,7 +15,7 @@ import {
ApplicationSelect,
} from "src/components/forms";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_MS_TEAMS_TENANTS,
@@ -28,6 +28,7 @@ import type {
MSTeamsTenant,
UseApiDataMap,
} from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
type MsTeamsTenantFormProps = {
msTeamsTenant?: UseApiDataMap<MSTeamsTenant>;
@@ -36,6 +37,7 @@ type MsTeamsTenantFormProps = {
export const MsTeamsTenantForm = ({
msTeamsTenant,
}: MsTeamsTenantFormProps) => {
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useServiceProviderData<Account[]>("Accounts");

View File

@@ -13,7 +13,6 @@ import {
withAccessControl,
useFilteredResults,
} from "src/utils";
import { toastError, toastSuccess } from "src/store";
import {
Icons,
Section,
@@ -29,8 +28,10 @@ import { DeleteMsTeamsTenant } from "./delete";
import type { Account, MSTeamsTenant, Application } from "src/api/types";
import type { ACLGetIMessage } from "src/utils/with-access-control";
import { useToast } from "src/components/toast/toast-provider";
export const MSTeamsTenants = () => {
const { toastSuccess, toastError } = useToast();
const [msTeamsTenant, setMsTeamsTenant] = useState<MSTeamsTenant | null>(
null,
);

View File

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

View File

@@ -20,7 +20,6 @@ import {
ROUTE_INTERNAL_CARRIERS,
ROUTE_INTERNAL_PHONE_NUMBERS,
} from "src/router/routes";
import { toastError, toastSuccess } from "src/store";
import { hasLength, useRedirect } from "src/utils";
import type {
@@ -31,12 +30,14 @@ import type {
UseApiDataMap,
} from "src/api/types";
import { setAccountFilter, setLocation } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
type PhoneNumberFormProps = {
phoneNumber?: UseApiDataMap<PhoneNumber>;
};
export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useServiceProviderData<Application[]>("Applications");

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useState, useRef } from "react";
import { Button, ButtonGroup, H1, Icon, MS } from "@jambonz/ui-kit";
import { Link } from "react-router-dom";
@@ -8,7 +8,7 @@ import {
putPhoneNumber,
useServiceProviderData,
} from "src/api";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
Icons,
Section,
@@ -16,28 +16,28 @@ import {
ApplicationFilter,
SearchFilter,
AccountFilter,
Pagination,
SelectFilter,
} from "src/components";
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_CARRIERS,
ROUTE_INTERNAL_PHONE_NUMBERS,
} from "src/router/routes";
import {
hasLength,
hasValue,
formatPhoneNumber,
useFilteredResults,
} from "src/utils";
import { hasLength, hasValue, formatPhoneNumber } from "src/utils";
import { DeletePhoneNumber } from "./delete";
import type { Account, PhoneNumber, Carrier, Application } from "src/api/types";
import { ENABLE_PHONE_NUMBER_LAZY_LOAD, USER_ACCOUNT } from "src/api/constants";
import { PER_PAGE_SELECTION, USER_ACCOUNT } from "src/api/constants";
import { ScopedAccess } from "src/components/scoped-access";
import { Scope } from "src/store/types";
import { getAccountFilter, setLocation } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
export const PhoneNumbers = () => {
const { toastSuccess, toastError } = useToast();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useServiceProviderData<Application[]>("Applications");
const [carriers] = useServiceProviderData<Carrier[]>("VoipCarriers");
@@ -51,35 +51,51 @@ export const PhoneNumbers = () => {
const [applyMassEdit, setApplyMassEdit] = useState(false);
const [filter, setFilter] = useState("");
const [accountSid, setAccountSid] = useState("");
const [phoneNumbersTotal, setphoneNumbersTotal] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [perPageFilter, setPerPageFilter] = useState("25");
const [maxPageNumber, setMaxPageNumber] = useState(1);
const phoneNumbersFiltered = useMemo(() => {
setAccountSid(getAccountFilter());
return phoneNumbers
? phoneNumbers.filter(
(phn) => !accountSid || phn.account_sid === accountSid,
)
: [];
}, [phoneNumbers, ...[!ENABLE_PHONE_NUMBER_LAZY_LOAD && accountSid]]);
// Add ref to track previous values
const prevValuesRef = useRef({
serviceProviderId: "",
accountSid: "",
filter: "",
pageNumber: 1,
perPageFilter: "25",
});
const filteredPhoneNumbers = !ENABLE_PHONE_NUMBER_LAZY_LOAD
? useFilteredResults<PhoneNumber>(filter, phoneNumbersFiltered)
: phoneNumbersFiltered;
const fetchPhoneNumbers = (resetPage = false) => {
setPhoneNumbers(null);
const refetch = () => {
getPhoneNumbers(
!ENABLE_PHONE_NUMBER_LAZY_LOAD
? {}
: {
...(accountSid && { account_sid: accountSid }),
...(filter && { filter }),
},
)
// Calculate the correct page to use
const currentPage = resetPage ? 1 : pageNumber;
// If we're resetting the page, also update the state
if (resetPage && pageNumber !== 1) {
setPageNumber(1);
}
const accSid = accountSid || getAccountFilter() || "";
getPhoneNumbers({
page: currentPage,
page_size: Number(perPageFilter),
...(accSid && { account_sid: accSid }),
...(filter && { filter }),
...(currentServiceProvider?.service_provider_sid && {
service_provider_sid: currentServiceProvider.service_provider_sid,
}),
})
.then(({ json }) => {
if (json) {
setPhoneNumbers(json);
setPhoneNumbers(json.data);
setphoneNumbersTotal(json.total);
setMaxPageNumber(Math.ceil(json.total / Number(perPageFilter)));
}
})
.catch((error) => {
setPhoneNumbers([]);
toastError(error.msg);
});
};
@@ -95,9 +111,11 @@ export const PhoneNumbers = () => {
}),
)
.then(() => {
refetch();
fetchPhoneNumbers(false);
setApplicationSid("");
setApplyMassEdit(false);
setSelectAll(false);
setSelectedPhoneNumbers([]);
toastSuccess("Number routing updated successfully");
})
.catch((error) => {
@@ -111,7 +129,7 @@ export const PhoneNumbers = () => {
if (phoneNumber) {
deletePhoneNumber(phoneNumber.phone_number_sid)
.then(() => {
refetch();
fetchPhoneNumbers(false);
setPhoneNumber(null);
toastSuccess(
<>
@@ -125,21 +143,43 @@ export const PhoneNumbers = () => {
}
};
// Initial account setup
useEffect(() => {
setLocation();
if (user?.account_sid && user.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
} else {
setAccountSid(getAccountFilter() || accountSid);
}
setLocation();
}, [user]);
// Combined effect for all data fetching
useEffect(() => {
if (ENABLE_PHONE_NUMBER_LAZY_LOAD) {
setPhoneNumbers([]);
return;
}
const prevValues = prevValuesRef.current;
const currentSPId = currentServiceProvider?.service_provider_sid;
refetch();
}, []);
// Detect changes that require page reset
const isFilterOrProviderChange =
prevValues.serviceProviderId !== currentSPId ||
prevValues.accountSid !== accountSid ||
prevValues.filter !== filter;
const isPageSizeChange =
prevValues.perPageFilter !== perPageFilter &&
prevValues.perPageFilter !== "25"; // Skip initial render
// Update ref for next comparison
prevValuesRef.current = {
serviceProviderId: currentSPId || "",
accountSid,
filter,
pageNumber,
perPageFilter,
};
// Fetch data with appropriate reset parameter
fetchPhoneNumbers(isFilterOrProviderChange || isPageSizeChange);
}, [currentServiceProvider, accountSid, filter, pageNumber, perPageFilter]);
return (
<>
@@ -160,6 +200,7 @@ export const PhoneNumbers = () => {
<SearchFilter
placeholder="Filter phone numbers"
filter={[filter, setFilter]}
delay={1000}
/>
<ScopedAccess user={user} scope={Scope.service_provider}>
<AccountFilter
@@ -168,25 +209,12 @@ export const PhoneNumbers = () => {
defaultOption
/>
</ScopedAccess>
{ENABLE_PHONE_NUMBER_LAZY_LOAD && (
<ButtonGroup>
<Button
small
onClick={() => {
setPhoneNumbers(null);
refetch();
}}
>
Search
</Button>
</ButtonGroup>
)}
</section>
<Section {...(hasLength(filteredPhoneNumbers) && { slim: true })}>
<Section {...(hasLength(phoneNumbers) && { slim: true })}>
<div className="list">
{!hasValue(phoneNumbers) ? (
<Spinner />
) : hasLength(filteredPhoneNumbers) ? (
) : hasLength(phoneNumbers) ? (
<>
<div className="item item--actions">
{accountSid ? (
@@ -200,7 +228,7 @@ export const PhoneNumbers = () => {
onChange={(e) => {
if (e.target.checked) {
setSelectAll(true);
setSelectedPhoneNumbers(filteredPhoneNumbers);
setSelectedPhoneNumbers(phoneNumbers);
} else {
setSelectAll(false);
setSelectedPhoneNumbers([]);
@@ -224,10 +252,8 @@ export const PhoneNumbers = () => {
<Button
small
onClick={() => {
handleMassEdit();
setSelectAll(false);
setApplyMassEdit(true);
setSelectedPhoneNumbers([]);
handleMassEdit();
}}
>
Apply
@@ -249,7 +275,7 @@ export const PhoneNumbers = () => {
</MS>
)}
</div>
{filteredPhoneNumbers.map((phoneNumber) => {
{phoneNumbers.map((phoneNumber) => {
return (
<div className="item" key={phoneNumber.phone_number_sid}>
<div className="item__info">
@@ -385,6 +411,26 @@ export const PhoneNumbers = () => {
</Button>
)}
</Section>
<footer>
<ButtonGroup>
<MS>
Total: {phoneNumbersTotal} record
{phoneNumbersTotal === 1 ? "" : "s"}
</MS>
{hasLength(phoneNumbers) && (
<Pagination
pageNumber={pageNumber}
setPageNumber={setPageNumber}
maxPageNumber={maxPageNumber}
/>
)}
<SelectFilter
id="page_filter"
filter={[perPageFilter, setPerPageFilter]}
options={PER_PAGE_SELECTION}
/>
</ButtonGroup>
</footer>
{phoneNumber && (
<DeletePhoneNumber
phoneNumber={phoneNumber}

View File

@@ -3,9 +3,9 @@ import React, { useEffect, useState } from "react";
import { getRecentCallLog } from "src/api";
import { RecentCall } from "src/api/types";
import { Icons, Spinner } from "src/components";
import { toastError, toastSuccess } from "src/store";
import { hasValue } from "src/utils";
import utc from "dayjs/plugin/utc";
import { useToast } from "src/components/toast/toast-provider";
dayjs.extend(utc);
type CallSystemLogsProps = {
@@ -29,6 +29,7 @@ const formatLog = (log: string): string => {
};
export default function CallSystemLogs({ call }: CallSystemLogsProps) {
const { toastError, toastSuccess } = useToast();
const [logs, setLogs] = useState<string[] | null>();
const [loading, setLoading] = useState(false);
const [count, setCount] = useState(0);

View File

@@ -8,7 +8,7 @@ import {
PER_PAGE_SELECTION,
USER_ACCOUNT,
} from "src/api/constants";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
Section,
AccountFilter,
@@ -28,6 +28,7 @@ import {
getQueryFilter,
setLocation,
} from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
const directionSelection = [
{ name: "either", value: "io" },
@@ -42,6 +43,7 @@ const statusSelection = [
];
export const RecentCalls = () => {
const { toastError } = useToast();
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
@@ -87,10 +89,10 @@ export const RecentCalls = () => {
};
useMemo(() => {
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
if (getQueryFilter()) {
const [date, direction, status] = getQueryFilter().split("/");
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
setDateFilter(date);
setDirectionFilter(direction);
setStatusFilter(status);

View File

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

View File

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

View File

@@ -22,13 +22,14 @@ import {
getSpansByNameRegex,
getSpansFromJaegerRoot,
} from "./utils";
import { toastError, toastSuccess } from "src/store";
import { useToast } from "src/components/toast/toast-provider";
type PlayerProps = {
call: RecentCall;
};
export const Player = ({ call }: PlayerProps) => {
const { toastSuccess, toastError } = useToast();
const { recording_url, call_sid } = call;
const url =
recording_url && recording_url.startsWith("http://")
@@ -160,6 +161,7 @@ export const Player = ({ call }: PlayerProps) => {
const drawSttRegionForSpan = (
s: JaegerSpan,
allSpans: JaegerSpan[],
startPoint: JaegerSpan,
channel = 0,
) => {
@@ -174,7 +176,36 @@ export const Player = ({ call }: PlayerProps) => {
const end =
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
const endSpeechTime = getSilenceStartTime(start, end, channel);
const verbHookSpans = getSpansByNameRegex(allSpans, /verb:hook/);
const verbHookSpan = verbHookSpans.find(
(v) => v.parentSpanId === s.spanId,
);
let verbHookDurantion = 0;
let latency = 0;
if (verbHookSpan) {
verbHookDurantion =
(verbHookSpan.endTimeUnixNano - verbHookSpan.startTimeUnixNano) /
1_000_000_000;
}
const [sttLatencyMs] = getSpanAttributeByName(
s.attributes,
"stt.latency_ms",
);
let endSpeechTime = 0;
if (!sttLatencyMs) {
endSpeechTime = getSilenceStartTime(start, end, channel);
latency = Number(
(end - endSpeechTime - verbHookDurantion).toFixed(2),
);
} else {
endSpeechTime =
end -
Number(sttLatencyMs.value.stringValue) / 1_000 -
verbHookDurantion;
latency = Number(sttLatencyMs.value.stringValue) / 1_000;
}
const [sttResult] = getSpanAttributeByName(s.attributes, "stt.result");
let att: WaveSurferSttResult;
@@ -186,7 +217,7 @@ export const Player = ({ call }: PlayerProps) => {
transcript: data.alternatives[0].transcript,
confidence: data.alternatives[0].confidence,
language_code: data.language_code,
...(endSpeechTime > 0 && { latency: end - endSpeechTime }),
latency,
};
const [sttResolve] = getSpanAttributeByName(
@@ -205,7 +236,7 @@ export const Player = ({ call }: PlayerProps) => {
color: "rgba(255, 255, 0, 0.55)",
drag: false,
resize: false,
content: `${(end - endSpeechTime).toFixed(2)}s`,
content: `${latency}s`,
});
changeRegionMouseStyle(latencyRegion, channel);
@@ -352,7 +383,7 @@ export const Player = ({ call }: PlayerProps) => {
if (startPoint) {
const gatherSpans = getSpansByNameRegex(spans, /:gather{/);
gatherSpans.forEach((s) => {
drawSttRegionForSpan(s, startPoint);
drawSttRegionForSpan(s, spans, startPoint);
});
// Trasscription
@@ -362,6 +393,7 @@ export const Player = ({ call }: PlayerProps) => {
const channel = Number(cs.name.split(":")[1]);
drawSttRegionForSpan(
cs,
spans,
startPoint,
channel > 0 ? channel - 1 : channel,
);

View File

@@ -13,7 +13,6 @@ import {
SystemInformation,
TtsCache,
} from "src/api/types";
import { toastError, toastSuccess } from "src/store";
import { Selector } from "src/components/forms";
import { hasValue, isvalidIpv4OrCidr } from "src/utils";
import {
@@ -22,8 +21,10 @@ import {
PASSWORD_MIN,
} from "src/api/constants";
import { Modal } from "src/components";
import { useToast } from "src/components/toast/toast-provider";
export const AdminSettings = () => {
const { toastSuccess, toastError } = useToast();
const [passwordSettings, passwordSettingsFetcher] =
useApiData<PasswordSettings>("PasswordSettings");
const [systemInformation, systemInformationFetcher] =

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { P, Button, ButtonGroup } from "@jambonz/ui-kit";
import { useDispatch, toastSuccess, toastError } from "src/store";
import { useDispatch } from "src/store";
import { hasLength } from "src/utils";
import {
putServiceProvider,
@@ -15,6 +15,7 @@ import { Checkzone, LocalLimits } from "src/components/forms";
import { withSelectState } from "src/utils";
import type { Limit, ServiceProvider } from "src/api/types";
import { removeActiveSP } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
export type ServiceProviderSettingsProps = {
serviceProviders: ServiceProvider[];
@@ -25,6 +26,7 @@ export const ServiceProviderSettings = ({
serviceProviders,
currentServiceProvider,
}: ServiceProviderSettingsProps) => {
const { toastSuccess, toastError } = useToast();
const dispatch = useDispatch();
const [limits, refetchLimits] = useServiceProviderData<Limit[]>("Limits");
const [name, setName] = useState("");

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { H1 } from "@jambonz/ui-kit";
import { useApiData } from "src/api";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { SpeechServiceForm } from "./form";
import type { SpeechCredential } from "src/api/types";
@@ -11,6 +11,7 @@ import { useScopedRedirect } from "src/utils/use-scoped-redirect";
import { Scope } from "src/store/types";
import { ROUTE_INTERNAL_SPEECH } from "src/router/routes";
import { useParams } from "react-router-dom";
import { useToast } from "src/components/toast/toast-provider";
export const EditSpeechService = () => {
const params = useParams();
@@ -18,6 +19,7 @@ export const EditSpeechService = () => {
const currentServiceProvider = useSelectState("currentServiceProvider");
const [url, setUrl] = useState("");
const [data, refetch, error] = useApiData<SpeechCredential>(url);
const { toastError } = useToast();
useScopedRedirect(
Scope.account,

View File

@@ -12,7 +12,7 @@ import {
Checkzone,
Message,
} from "src/components/forms";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
deleteGoogleCustomVoice,
getGoogleCustomVoices,
@@ -52,6 +52,11 @@ import {
VENDOR_CARTESIA,
VENDOR_VOXIST,
VENDOR_OPENAI,
VENDOR_INWORLD,
VENDOR_DEEPGRAM_FLUX,
VENDOR_RESEMBLE,
VENDOR_HOUNDIFY,
VENDOR_GLADIA,
} from "src/vendor";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import {
@@ -80,9 +85,13 @@ import type {
import { setAccountFilter, setLocation } from "src/store/localStore";
import {
ADDITIONAL_SPEECH_VENDORS,
ASSEMBLYAI_STT_VERSIONS,
DEEPGRAM_STT_ENPOINT,
DEFAULT_ASSEMBLYAI_STT_VERSION,
DEFAULT_CARTESIA_OPTIONS,
DEFAULT_ELEVENLABS_OPTIONS,
DEFAULT_GOOGLE_CUSTOM_VOICE,
DEFAULT_INWORLD_OPTIONS,
DEFAULT_PLAYHT_OPTIONS,
DEFAULT_RIMELABS_OPTIONS,
DEFAULT_VERBIO_MODEL,
@@ -91,14 +100,23 @@ import {
GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
VERBIO_STT_MODELS,
} from "src/api/constants";
import { useToast } from "src/components/toast/toast-provider";
type SpeechServiceFormProps = {
credential?: UseApiDataMap<SpeechCredential>;
};
export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const { toastError, toastSuccess } = useToast();
const navigate = useNavigate();
const user = useSelectState("user");
// ElevenLabs API URI options
const ELEVENLABS_API_URI_OPTIONS = [
{ name: "US", value: "api.elevenlabs.io" },
{ name: "EU", value: "api.eu.residency.elevenlabs.io" },
{ name: "IN", value: "api.in.residency.elevenlabs.io" },
];
const currentServiceProvider = useSelectState("currentServiceProvider");
const regions = useRegionVendors();
const [accounts] = useServiceProviderData<Account[]>("Accounts");
@@ -112,11 +130,13 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
);
const [region, setRegion] = useState("");
const [apiKey, setApiKey] = useState("");
const [apiUri, setApiUri] = useState("api.elevenlabs.io");
const [userId, setUserId] = useState("");
const [accessKeyId, setAccessKeyId] = useState("");
const [secretAccessKey, setSecretAccessKey] = useState("");
const [clientId, setClientId] = useState("");
const [secretKey, setSecretKey] = useState("");
const [clientKey, setClientKey] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [googleServiceKey, setGoogleServiceKey] =
useState<GoogleServiceKey | null>(null);
@@ -127,6 +147,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const [ttsModelId, setTtsModelId] = useState("");
const [sttModelId, setSttModelId] = useState("");
const [engineVersion, setEngineVersion] = useState(DEFAULT_VERBIO_MODEL);
const [serviceVersion, setServiceVersion] = useState(
DEFAULT_ASSEMBLYAI_STT_VERSION,
);
const [instanceId, setInstanceId] = useState("");
const [initialCheckCustomTts, setInitialCheckCustomTts] = useState(false);
const [initialCheckCustomStt, setInitialCheckCustomStt] = useState(false);
@@ -196,6 +219,13 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const [tmpPlayhtTtsUri, setTmpPlayhtTtsUri] = useState("");
const [initialPlayhtOnpremCheck, setInitialPlayhtOnpremCheck] =
useState(false);
const [resembleTtsUri, setResembleTtsUri] = useState("");
const [tmpResembleTtsUri, setTmpResembleTtsUri] = useState("");
const [initialResembleOnpremCheck, setInitialResembleOnpremCheck] =
useState(false);
const [resembleTtsUseTls, setResembleTtsUseTls] = useState(false);
const [tmpResembleTtsUseTls, setTmpResembleTtsUseTls] = useState(false);
const [houndifyServerUri, setHoundifyServerUri] = useState("");
const handleFile = (file: File) => {
const handleError = () => {
setGoogleServiceKey(null);
@@ -231,6 +261,8 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
return DEFAULT_PLAYHT_OPTIONS;
case VENDOR_RIMELABS:
return DEFAULT_RIMELABS_OPTIONS;
case VENDOR_INWORLD:
return DEFAULT_INWORLD_OPTIONS;
case VENDOR_CARTESIA:
return DEFAULT_CARTESIA_OPTIONS;
}
@@ -247,6 +279,8 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
return "https://docs.play.ht/reference/api-generate-tts-audio-stream";
case VENDOR_RIMELABS:
return "https://rimelabs.mintlify.app/api-reference/endpoint/streaming-mp3#variable-parameters";
case VENDOR_INWORLD:
return "https://docs.inworld.ai/api-reference/ttsAPI/texttospeech/synthesize-speech-stream";
case VENDOR_CARTESIA:
return "https://docs.cartesia.ai/api-reference/tts/bytes";
}
@@ -258,7 +292,20 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
switch (vendor) {
case VENDOR_PLAYHT:
return "Voice Engine";
case VENDOR_DEEPGRAM:
return "Model ID";
case VENDOR_CARTESIA:
return "TTS Model ID";
default:
return "Model";
}
};
const getSTTModelLabelByVendor = (vendor: Lowercase<Vendor>) => {
switch (vendor) {
case VENDOR_CARTESIA:
return " STT Model ID";
case VENDOR_DEEPGRAM:
return "Model ID";
default:
return "Model";
@@ -394,6 +441,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
}),
...(vendor === VENDOR_CARTESIA && {
model_id: ttsModelId || null,
stt_model_id: sttModelId || null,
options: options || null,
}),
...(vendor === VENDOR_CUSTOM && {
@@ -411,16 +459,27 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
nuance_tts_uri: onPremNuanceTtsUrl || null,
nuance_stt_uri: onPremNuanceSttUrl || null,
}),
...(vendor === VENDOR_HOUNDIFY && {
client_id: clientId || null,
client_key: clientKey || null,
user_id: userId || null,
houndify_server_uri: houndifyServerUri || null,
}),
...(vendor === VENDOR_COBALT && {
cobalt_server_uri: cobaltServerUri || null,
}),
...((vendor === VENDOR_ELEVENLABS ||
vendor === VENDOR_WHISPER ||
vendor === VENDOR_INWORLD ||
vendor === VENDOR_RIMELABS) && {
model_id: ttsModelId || null,
}),
...(vendor === VENDOR_ELEVENLABS && {
api_uri: apiUri || null,
}),
...((vendor === VENDOR_ELEVENLABS ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_INWORLD ||
vendor === VENDOR_RIMELABS) && {
options: options || null,
}),
@@ -436,6 +495,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
deepgram_stt_uri: deepgramSttUri || null,
deepgram_tts_uri: deepgramTtsUri || null,
deepgram_stt_use_tls: deepgramSttUseTls ? 1 : 0,
model_id: sttModelId || null,
}),
...(vendor === VENDOR_SPEECHMATICS && {
speechmatics_stt_uri: speechmaticsEndpoint || null,
@@ -443,9 +503,20 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
...(vendor === VENDOR_VERBIO && {
engine_version: engineVersion,
}),
...(vendor === VENDOR_ASSEMBLYAI && {
service_version: serviceVersion || null,
}),
...(vendor === VENDOR_PLAYHT && {
playht_tts_uri: playhtTtsUri || null,
}),
...(vendor === VENDOR_RESEMBLE && {
resemble_tts_uri: resembleTtsUri || null,
resemble_tts_use_tls: resembleTtsUseTls ? 1 : 0,
}),
...(vendor === VENDOR_GLADIA && {
api_key: apiKey || null,
region: region || null,
}),
};
if (credential && credential.data) {
@@ -492,9 +563,13 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
vendor === VENDOR_ELEVENLABS ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_INWORLD ||
vendor === VENDOR_WHISPER ||
vendor === VENDOR_CARTESIA ||
vendor === VENDOR_OPENAI
vendor === VENDOR_OPENAI ||
vendor === VENDOR_RESEMBLE ||
vendor === VENDOR_DEEPGRAM_FLUX ||
vendor === VENDOR_GLADIA
? apiKey
: null,
}),
@@ -561,8 +636,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
vendor === VENDOR_WHISPER ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_INWORLD ||
vendor === VENDOR_CARTESIA ||
vendor === VENDOR_OPENAI
vendor === VENDOR_OPENAI ||
vendor === VENDOR_DEEPGRAM
) {
getSpeechSupportedLanguagesAndVoices(
currentServiceProvider?.service_provider_sid,
@@ -572,21 +649,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
).then(({ json }) => {
if (json.models) {
setTtsModels(json.models);
if (
json.models.length > 0 &&
!json.models.find((m) => m.value === ttsModelId)
) {
setTtsModelId(json.models[0].value);
}
}
if (json.sttModels) {
setSttModels(json.sttModels);
if (
json.sttModels.length > 0 &&
!json.sttModels.some((m) => m.value === sttModelId)
) {
setSttModelId(json.sttModels[0].value);
}
}
});
} else {
@@ -594,6 +659,24 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
}
}, [vendor]);
useEffect(() => {
const modelId = credential?.data?.model_id || "";
if (sttModels.length > 0 && !sttModels.some((m) => m.value === modelId)) {
setSttModelId(sttModels[0].value);
} else {
setSttModelId(modelId);
}
}, [credential, sttModels]);
useEffect(() => {
const modelId = credential?.data?.model_id || "";
if (ttsModels.length > 0 && !ttsModels.some((m) => m.value === modelId)) {
setTtsModelId(ttsModels[0].value);
} else {
setTtsModelId(modelId);
}
}, [credential, ttsModels]);
useEffect(() => {
setLocation();
if (credential && credential.data) {
@@ -640,6 +723,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setApiKey(credential.data.api_key);
}
if (credential.data.api_uri) {
setApiUri(credential.data.api_uri);
}
if (credential.data.region) {
setRegion(credential.data.region);
}
@@ -651,6 +738,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
if (credential.data.client_id) {
setClientId(credential.data.client_id);
}
if (credential.data.client_key) {
setClientKey(credential.data.client_key);
}
if (credential.data.secret) {
setSecretKey(credential.data.secret);
@@ -742,12 +832,20 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
if (credential.data.model_id) {
setTtsModelId(credential.data.model_id);
}
if (credential.data.model_id && vendor === VENDOR_OPENAI) {
if (
credential.data.model_id &&
(vendor === VENDOR_OPENAI || vendor === VENDOR_DEEPGRAM)
) {
setSttModelId(credential.data.model_id);
} else if (credential.data.stt_model_id) {
setSttModelId(credential.data.stt_model_id);
}
if (credential?.data?.playht_tts_uri) {
setPlayhtTtsUri(credential.data.playht_tts_uri);
}
if (credential?.data?.resemble_tts_uri) {
setResembleTtsUri(credential.data.resemble_tts_uri);
}
}
if (credential?.data?.options) {
setOptions(credential.data.options);
@@ -762,9 +860,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setUseCustomVoicesCheck(json.length > 0);
});
}
if (credential?.data?.deepgram_stt_uri) {
setDeepgramSttUri(credential.data.deepgram_stt_uri);
}
setDeepgramSttUri(credential?.data?.deepgram_stt_uri || "");
if (credential?.data?.deepgram_tts_uri) {
setDeepgramTtsUri(credential.data.deepgram_tts_uri);
}
@@ -773,12 +869,21 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
credential?.data?.deepgram_stt_use_tls > 0 ? true : false,
);
}
setInitialDeepgramOnpremCheck(hasValue(credential?.data?.deepgram_stt_uri));
setInitialDeepgramOnpremCheck(
hasValue(credential?.data?.deepgram_stt_uri) &&
!DEEPGRAM_STT_ENPOINT.map((e) => e.value).includes(
credential?.data?.deepgram_stt_uri,
),
);
if (credential?.data?.user_id) {
setUserId(credential.data.user_id);
}
if (credential?.data?.houndify_server_uri) {
setHoundifyServerUri(credential.data.houndify_server_uri);
}
if (credential?.data?.voice_engine) {
setTtsModelId(credential.data.voice_engine);
}
@@ -803,6 +908,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
if (credential?.data?.engine_version) {
setEngineVersion(credential.data.engine_version);
}
if (credential?.data?.service_version) {
setServiceVersion(credential.data.service_version);
}
if (credential?.data?.speechmatics_stt_uri) {
setInitialSpeechMaticsOnpremCheck(
@@ -811,6 +919,15 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setSpeechmaticsEndpoint(credential.data.speechmatics_stt_uri);
}
setInitialPlayhtOnpremCheck(hasValue(credential?.data?.playht_tts_uri));
setInitialResembleOnpremCheck(hasValue(credential?.data?.resemble_tts_uri));
if (credential?.data?.resemble_tts_use_tls) {
setResembleTtsUseTls(
credential?.data?.resemble_tts_use_tls > 0 ? true : false,
);
setTmpResembleTtsUseTls(
credential?.data?.resemble_tts_use_tls > 0 ? true : false,
);
}
}, [credential]);
const updateCustomVoices = (
@@ -874,6 +991,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setVendor(e.target.value as Lowercase<Vendor>);
setRegion("");
setApiKey("");
setApiUri(
e.target.value === VENDOR_ELEVENLABS ? "api.elevenlabs.io" : "",
);
setGoogleServiceKey(null);
}}
disabled={credential ? true : false}
@@ -929,7 +1049,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
vendor !== VENDOR_COBALT &&
vendor !== VENDOR_SONIOX &&
vendor !== VENDOR_SPEECHMATICS &&
vendor !== VENDOR_DEEPGRAM_FLUX &&
vendor !== VENDOR_HOUNDIFY &&
vendor !== VENDOR_OPENAI &&
vendor !== VENDOR_GLADIA &&
vendor != VENDOR_CUSTOM && (
<label htmlFor="use_for_tts" className="chk">
<input
@@ -947,7 +1070,8 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
vendor !== VENDOR_WHISPER &&
vendor !== VENDOR_PLAYHT &&
vendor !== VENDOR_RIMELABS &&
vendor !== VENDOR_CARTESIA &&
vendor !== VENDOR_INWORLD &&
vendor !== VENDOR_RESEMBLE &&
vendor !== VENDOR_ELEVENLABS && (
<label htmlFor="use_for_stt" className="chk">
<input
@@ -1327,6 +1451,56 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
)}
</>
)}
{vendor === VENDOR_HOUNDIFY && (
<fieldset>
<label htmlFor="houndify_client_id">
Client ID
{!onPremNuanceSttCheck && !onPremNuanceTtsCheck && <span>*</span>}
</label>
<input
id="houndify_client_id"
required={!onPremNuanceSttCheck && !onPremNuanceTtsCheck}
type="text"
name="houndify_client_id"
placeholder="Client ID"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
disabled={credential ? true : false}
/>
<label htmlFor="houndify_secret">
Client Key
{!onPremNuanceSttCheck && !onPremNuanceTtsCheck && <span>*</span>}
</label>
<Passwd
id="houndify_secret"
required={!onPremNuanceSttCheck && !onPremNuanceTtsCheck}
name="houndify_secret"
placeholder="Client Key"
value={clientKey ? getObscuredSecret(clientKey) : clientKey}
onChange={(e) => setClientKey(e.target.value)}
disabled={credential ? true : false}
/>
<label htmlFor="houndify_user_id">User ID</label>
<input
id="houndify_user_id"
type="text"
name="houndify_user_id"
placeholder="User ID"
value={userId}
onChange={(e) => setUserId(e.target.value)}
disabled={credential ? true : false}
/>
<label htmlFor="houndify_server_uri">Audio Endpoint</label>
<input
id="houndify_server_uri"
type="text"
name="houndify_server_uri"
placeholder="Audio Endpoint (optional)"
value={houndifyServerUri}
onChange={(e) => setHoundifyServerUri(e.target.value)}
/>
</fieldset>
)}
{vendor === VENDOR_NUANCE && (
<>
<fieldset>
@@ -1483,6 +1657,22 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
</fieldset>
</>
)}
{vendor === VENDOR_ASSEMBLYAI && (
<fieldset>
<label htmlFor={`${vendor}_tts_model_id`}>
Service version<span>*</span>
</label>
<Selector
id={"assemblyai_service_version"}
name={"assemblyai_service_version"}
value={serviceVersion}
options={ASSEMBLYAI_STT_VERSIONS}
onChange={(e) => {
setServiceVersion(e.target.value);
}}
/>
</fieldset>
)}
{vendor === VENDOR_AWS && (
<fieldset>
<label htmlFor="vendor">
@@ -1671,16 +1861,98 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
</fieldset>
)}
{vendor === VENDOR_RESEMBLE && (
<fieldset>
<Checkzone
disabled={hasValue(credential)}
hidden
name="use_on-prem_resemble_container"
label="Use on-prem Resemble container"
initialCheck={initialResembleOnpremCheck}
handleChecked={(e) => {
setInitialResembleOnpremCheck(e.target.checked);
if (e.target.checked) {
if (tmpResembleTtsUri) {
setResembleTtsUri(tmpResembleTtsUri);
}
if (tmpResembleTtsUseTls) {
setResembleTtsUseTls(tmpResembleTtsUseTls);
}
} else {
setTmpResembleTtsUri(resembleTtsUri);
setResembleTtsUri("");
setTmpResembleTtsUseTls(resembleTtsUseTls);
setResembleTtsUseTls(false);
}
}}
>
<label htmlFor="resemble_uri_for_tts">
TTS Container URI<span>*</span>
</label>
<input
id="resemble_uri_for_tts"
required
type="text"
name="resemble_uri_for_tts"
placeholder=""
value={resembleTtsUri}
onChange={(e) => setResembleTtsUri(e.target.value)}
/>
<label htmlFor="resemble_stt_use_tls" className="chk">
<input
id="resemble_stt_use_tls"
name="resemble_stt_use_tls"
type="checkbox"
onChange={(e) => setResembleTtsUseTls(e.target.checked)}
defaultChecked={resembleTtsUseTls}
/>
<div>Use TLS</div>
</label>
</Checkzone>
</fieldset>
)}
{vendor === VENDOR_ELEVENLABS && (
<fieldset>
<label htmlFor="elevenlabs_api_uri">
Data residency<span>*</span>
</label>
<Selector
id="elevenlabs_api_uri"
name="elevenlabs_api_uri"
value={apiUri}
options={ELEVENLABS_API_URI_OPTIONS}
onChange={(e) => setApiUri(e.target.value)}
required
/>
<label htmlFor={`${vendor}_apikey`}>
API key<span>*</span>
</label>
<Passwd
id={`${vendor}_apikey`}
required
name={`${vendor}_apikey`}
placeholder="API key"
value={apiKey ? getObscuredSecret(apiKey) : apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={credential ? true : false}
/>
</fieldset>
)}
{(vendor === VENDOR_WELLSAID ||
vendor === VENDOR_ASSEMBLYAI ||
vendor === VENDOR_VOXIST ||
vendor == VENDOR_ELEVENLABS ||
vendor === VENDOR_WHISPER ||
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_INWORLD ||
vendor === VENDOR_SONIOX ||
vendor === VENDOR_CARTESIA ||
vendor === VENDOR_OPENAI ||
vendor === VENDOR_SPEECHMATICS) && (
vendor === VENDOR_DEEPGRAM_FLUX ||
vendor === VENDOR_RESEMBLE ||
vendor === VENDOR_SPEECHMATICS ||
vendor === VENDOR_GLADIA) && (
<fieldset>
<label htmlFor={`${vendor}_apikey`}>
API key<span>*</span>
@@ -1696,11 +1968,12 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
/>
</fieldset>
)}
{(vendor == VENDOR_ELEVENLABS ||
vendor == VENDOR_WHISPER ||
vendor === VENDOR_CARTESIA ||
{(vendor === VENDOR_ELEVENLABS ||
vendor === VENDOR_WHISPER ||
vendor === VENDOR_PLAYHT ||
vendor == VENDOR_RIMELABS) &&
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_INWORLD ||
(ttsCheck && vendor === VENDOR_CARTESIA)) &&
ttsModels.length > 0 && (
<fieldset>
<label htmlFor={`${vendor}_tts_model_id`}>
@@ -1717,26 +1990,30 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
/>
</fieldset>
)}
{vendor == VENDOR_OPENAI && sttModels.length > 0 && (
<fieldset>
<label htmlFor={`${vendor}_stt_model_id`}>
{getModelLabelByVendor(vendor)}
</label>
<Selector
id={"stt_model_id"}
name={"stt_model_id"}
value={sttModelId}
options={sttModels}
onChange={(e) => {
setSttModelId(e.target.value);
}}
/>
</fieldset>
)}
{(vendor == VENDOR_OPENAI ||
vendor === VENDOR_DEEPGRAM ||
(sttCheck && vendor === VENDOR_CARTESIA)) &&
sttModels.length > 0 && (
<fieldset>
<label htmlFor={`${vendor}_stt_model_id`}>
{getSTTModelLabelByVendor(vendor)}
</label>
<Selector
id={"stt_model_id"}
name={"stt_model_id"}
value={sttModelId}
options={sttModels}
onChange={(e) => {
setSttModelId(e.target.value);
}}
/>
</fieldset>
)}
{(vendor === VENDOR_ELEVENLABS ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_CARTESIA ||
vendor === VENDOR_RIMELABS) && (
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_INWORLD) && (
<fieldset>
<Checkzone
hidden
@@ -1957,6 +2234,19 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
onChange={(e) => setApiKey(e.target.value)}
disabled={credential ? true : false}
/>
<label htmlFor={`${vendor}_deepgram_stt_enpoint`}>
Deepgram STT Endpoint<span>*</span>
</label>
<Selector
id={"deepgram_stt_enpoint"}
name={"deepgram_stt_enpoint"}
value={deepgramSttUri}
options={DEEPGRAM_STT_ENPOINT}
onChange={(e) => {
setDeepgramSttUri(e.target.value);
setDeepgramSttUseTls(hasValue(e.target.value));
}}
/>
</Checkzone>
<Checkzone
disabled={hasValue(credential)}

View File

@@ -4,7 +4,7 @@ import { Link } from "react-router-dom";
import { USER_ACCOUNT } from "src/api/constants";
import { AccountFilter, Icons, Section, Spinner } from "src/components";
import { useSelectState, toastError, toastSuccess } from "src/store";
import { useSelectState } from "src/store";
import {
deleteSpeechService,
useServiceProviderData,
@@ -26,8 +26,10 @@ import { ScopedAccess } from "src/components/scoped-access";
import { Scope } from "src/store/types";
import { getAccountFilter, setLocation } from "src/store/localStore";
import { VENDOR_CUSTOM } from "src/vendor";
import { useToast } from "src/components/toast/toast-provider";
export const SpeechServices = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [apiUrl, setApiUrl] = useState("");

View File

@@ -5,11 +5,12 @@ import { useParams } from "react-router-dom";
import { UserForm } from "./form";
import { useApiData } from "src/api";
import { User } from "src/api/types";
import { toastError } from "src/store";
import { useToast } from "src/components/toast/toast-provider";
export const EditUser = () => {
const params = useParams();
const [data, refetch, error] = useApiData<User>(`Users/${params.user_sid}`);
const { toastError } = useToast();
/** Handle error toast at top level... */
useEffect(() => {

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
import { Link, useNavigate } from "react-router-dom";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
deleteUser,
postFetch,
@@ -38,12 +38,14 @@ import type {
} from "src/api/types";
import type { IMessage } from "src/store/types";
import { setAccountFilter, setLocation } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
type UserFormProps = {
user?: UseApiDataMap<User>;
};
export const UserForm = ({ user }: UserFormProps) => {
const { toastSuccess, toastError } = useToast();
const { signout } = useAuth();
const navigate = useNavigate();
const currentUser = useSelectState("user");

View File

@@ -8,9 +8,10 @@ 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";
import { useToast } from "src/components/toast/toast-provider";
export const ForgotPassword = () => {
const { toastSuccess } = useToast();
const [message, setMessage] = useState("");
const [email, setEmail] = useState("");
const navigate = useNavigate();

View File

@@ -2,7 +2,6 @@ import React, { useEffect, useState } from "react";
import { Button, H1 } from "@jambonz/ui-kit";
import { useLocation, Navigate, Link } from "react-router-dom";
import { toastError, toastSuccess } from "src/store";
import { getToken, parseJwt, useAuth } from "src/router/auth";
import {
SESS_FLASH_MSG,
@@ -26,8 +25,10 @@ import { v4 as uuid } from "uuid";
import { setLocationBeforeOauth, setOauthState } from "src/store/localStore";
import { getGithubOauthUrl, getGoogleOauthUrl } from "./utils";
import { UserData } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
export const Login = () => {
const { toastSuccess, toastError } = useToast();
const state = uuid();
setOauthState(state);
setLocationBeforeOauth("/sign-in");

View File

@@ -8,6 +8,7 @@ import {
BASE_URL,
} from "src/api/constants";
import { Spinner } from "src/components";
import { useToast } from "src/components/toast/toast-provider";
import { setToken } from "src/router/auth";
import {
ROUTE_INTERNAL_ACCOUNTS,
@@ -15,7 +16,6 @@ import {
ROUTE_REGISTER,
ROUTE_REGISTER_SUB_DOMAIN,
} from "src/router/routes";
import { toastError } from "src/store";
import {
getLocationBeforeOauth,
getOauthState,
@@ -25,6 +25,7 @@ import {
} from "src/store/localStore";
export const OauthCallback = () => {
const { toastError } = useToast();
const queryParams = new URLSearchParams(location.search);
const code = queryParams.get("code");
const newState = queryParams.get("state");

View File

@@ -7,10 +7,11 @@ 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";
import { useToast } from "src/components/toast/toast-provider";
export const RegisterEmail = () => {
const { toastError } = useToast();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const navigate = useNavigate();

View File

@@ -2,15 +2,16 @@ 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 { useToast } from "src/components/toast/toast-provider";
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 { toastError } = useToast();
const [code, setCode] = useState("");
const userData: UserData = parseJwt(getToken());
const navigate = useNavigate();

View File

@@ -3,11 +3,12 @@ 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 { useToast } from "src/components/toast/toast-provider";
import { setToken } from "src/router/auth";
import { ROUTE_LOGIN } from "src/router/routes";
import { toastError, toastSuccess } from "src/store";
export const ResetPassword = () => {
const { toastError, toastSuccess } = useToast();
const params = useParams();
const resetId = params.id;
const [newPassword, setNewPassword] = useState("");

View File

@@ -7,17 +7,20 @@ import { AuthProvider } from "./router/auth";
import { Router } from "./router";
import "./styles/index.scss";
import { ToastProvider } from "./components/toast/toast-provider";
const root: Element = document.getElementById("root")!;
createRoot(root).render(
<React.StrictMode>
<StateProvider>
<BrowserRouter>
<AuthProvider>
<Router />
</AuthProvider>
</BrowserRouter>
</StateProvider>
<ToastProvider>
<StateProvider>
<BrowserRouter>
<AuthProvider>
<Router />
</AuthProvider>
</BrowserRouter>
</StateProvider>
</ToastProvider>
</React.StrictMode>,
);

View File

@@ -25,11 +25,12 @@ import {
import type { UserLogin } from "src/api/types";
import { ENABLE_HOSTED_SYSTEM, USER_ACCOUNT } from "src/api/constants";
import type { UserData } from "src/store/types";
import { toastError } from "src/store";
import {
clearLocalStorage,
removeLocationBeforeOauth,
removeOauthState,
} from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
interface SignIn {
(username: string, password: string): Promise<UserLogin>;
@@ -93,6 +94,7 @@ export const parseJwt = (token: string) => {
* Provider hook that creates auth object and handles state
*/
export const useProvideAuth = (): AuthStateContext => {
const { toastError } = useToast();
let token = getToken();
let userData: UserData;
const navigate = useNavigate();
@@ -163,7 +165,7 @@ export const useProvideAuth = (): AuthStateContext => {
postLogout()
.then((response) => {
if (response.status === StatusCodes.NO_CONTENT) {
localStorage.clear();
clearLocalStorage();
sessionStorage.clear();
sessionStorage.setItem(SESS_FLASH_MSG, MSG_LOGGED_OUT);
window.location.href = ROUTE_LOGIN;

View File

@@ -1,6 +1,5 @@
import React, { useReducer, useContext } from "react";
import { TOAST_TIME } from "src/constants";
import {
genericAction,
userAsyncAction,
@@ -11,8 +10,6 @@ import {
} from "./actions";
import type {
IMessage,
Toast,
State,
Action,
MiddleWare,
@@ -49,22 +46,12 @@ const reducer: React.Reducer<State, Action<keyof State>> = (state, action) => {
}
};
let toastTimeout: number;
/** Async middlewares */
/** Proxies dispatch to reducer */
const middleware: MiddleWare = (dispatch) => {
/** This generic implementation enforces global dispatch type-safety */
return <Type extends keyof State>(action: Action<Type>) => {
switch (action.type) {
case "toast":
if (toastTimeout) {
clearTimeout(toastTimeout);
}
toastTimeout = setTimeout(() => {
dispatch({ type: "toast" });
}, TOAST_TIME);
return dispatch(action);
case "user":
return userAsyncAction().then((payload) => {
dispatch({ ...action, payload });
@@ -106,28 +93,6 @@ export const useDispatch = (): GlobalDispatch => {
return globalDispatch;
};
/** Toast dispatch helpers to make component code less cumbersome */
const toastDispatch = (payload: Toast) => {
globalDispatch({
type: "toast",
payload,
});
};
export const toastError = (msg: IMessage) => {
toastDispatch({
type: "error",
message: msg,
});
};
export const toastSuccess = (msg: IMessage) => {
toastDispatch({
type: "success",
message: msg,
});
};
/** Wrapper hook for state context */
export const useStateContext = () => {
const { state } = useContext(StateContext);

View File

@@ -123,7 +123,18 @@ export const checkLocation = () => {
if (currentLocation !== storedLocation) {
localStorage.removeItem(storeQueryFilter);
localStorage.removeItem(storeAccountFilter);
// Keep storeAccountFilter in different location that user can search for same account
// in different location
// localStorage.removeItem(storeAccountFilter);
return;
}
};
export const clearLocalStorage = () => {
const toKeep = [storeActiveSP, storeAccountFilter];
Object.keys(localStorage).forEach((key) => {
if (!toKeep.includes(key)) {
localStorage.removeItem(key);
}
});
};

View File

@@ -7,12 +7,12 @@ input[type="text"],
input[type="email"],
input[type="number"],
input[type="password"] {
@include ui-mixins.m();
padding: ui-vars.$px01 ui-vars.$px02;
border-radius: ui-vars.$px01;
border: 2px solid ui-vars.$grey;
background-color: ui-vars.$white;
color: inherit;
@include ui-mixins.m();
&:focus {
border-color: ui-vars.$dark;
@@ -110,11 +110,11 @@ fieldset {
}
label {
@include ui-mixins.m();
@include ui-mixins.font-medium();
display: flex;
align-items: center;
flex-wrap: wrap;
@include ui-mixins.font-medium();
@include ui-mixins.m();
span {
color: ui-vars.$jambonz;
@@ -218,7 +218,8 @@ fieldset {
}
}
.gateway {
.gateway,
.gateway-inbound {
padding: ui-vars.$px02;
border-radius: ui-vars.$px01;
border: 2px solid ui-vars.$grey;
@@ -284,6 +285,18 @@ fieldset {
}
}
.gateway-inbound {
> div {
&:nth-child(1) {
grid-template-columns: [col] calc(70% - #{ui-vars.$px02 * 2}) [col] 30%;
@include mixins.small() {
grid-template-columns: [col] 100%;
}
}
}
}
.lcr {
@extend .gateway;

View File

@@ -21,10 +21,10 @@
}
&__row {
@include ui-mixins.m();
display: grid;
padding: ui-vars.$px03;
align-items: center;
@include ui-mixins.m();
@include mixins.small() {
padding: ui-vars.$px02;

View File

@@ -30,11 +30,11 @@
}
&__title {
@include ui-mixins.p();
display: flex;
align-items: center;
grid-gap: ui-vars.$px02;
color: ui-vars.$jambonz;
@include ui-mixins.p();
+ .item__meta {
@include mixins.small() {

View File

@@ -1,3 +1,4 @@
@use "sass:color";
@use "./forms";
// @use "./cards";
@use "./lists";
@@ -68,7 +69,7 @@ details {
&.ok {
color: ui-vars.$teal;
border: 2px solid ui-vars.$teal;
background-color: mix(ui-vars.$white, ui-vars.$teal, 95%);
background-color: color.mix(ui-vars.$white, ui-vars.$teal, 95%);
}
&.not-tested {
@@ -77,8 +78,8 @@ details {
}
summary {
@include ui-mixins.m();
cursor: pointer;
@include ui-mixins.m();
+ * {
margin-top: ui-vars.$px02;
@@ -160,7 +161,6 @@ details {
/** Used for recent-calls */
.pre-grid {
@include mixins.code();
display: grid;
grid-template-columns: auto 1fr;
grid-row-gap: ui-vars.$px01;
@@ -171,6 +171,7 @@ details {
background-color: ui-vars.$dark;
border-radius: ui-vars.$px01;
margin-top: ui-vars.$px02;
@include mixins.code();
}
.pre-grid-white {

View File

@@ -1,7 +1,6 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toastError } from "src/store";
import { useToast } from "src/components/toast/toast-provider";
import type { IMessage } from "src/store/types";
@@ -11,6 +10,7 @@ export const useRedirect = <Type>(
message: IMessage,
) => {
const navigate = useNavigate();
const { toastError } = useToast();
useEffect(() => {
if (collection && !collection.length) {

View File

@@ -7,8 +7,9 @@ import {
SpeechCredential,
User,
} from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { IMessage, Scope, UserData } from "src/store/types";
@@ -21,6 +22,7 @@ export const useScopedRedirect = (
) => {
const navigate = useNavigate();
const currentServiceProvider = useSelectState("currentServiceProvider");
const { toastError } = useToast();
useEffect(() => {
if (
@@ -47,5 +49,5 @@ export const useScopedRedirect = (
navigate(redirect);
}
}, [user, currentServiceProvider, data]);
}, [user, currentServiceProvider, data, toastError]);
};

View File

@@ -2,11 +2,12 @@ import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toastError, useSelectState, useAccessControl } from "src/store";
import { useSelectState, useAccessControl } from "src/store";
import { ROUTE_INTERNAL_SETTINGS } from "src/router/routes";
import type { ACL, IMessage } from "src/store/types";
import type { ServiceProvider } from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
type PassthroughProps = {
[key: string]: unknown;
@@ -22,6 +23,7 @@ export const withAccessControl = (
) => {
return function WithAccessControl(Component: React.ComponentType) {
return function ComponentWithAccessControl(props: PassthroughProps) {
const { toastError } = useToast();
const navigate = useNavigate();
const hasPermission = useAccessControl(acl);
const currentServiceProvider = useSelectState("currentServiceProvider");

28
src/vendor/index.tsx vendored
View File

@@ -12,6 +12,7 @@ export const VENDOR_MICROSOFT = "microsoft";
export const VENDOR_WELLSAID = "wellsaid";
export const VENDOR_NUANCE = "nuance";
export const VENDOR_DEEPGRAM = "deepgram";
export const VENDOR_DEEPGRAM_FLUX = "deepgramflux";
export const VENDOR_IBM = "ibm";
export const VENDOR_NVIDIA = "nvidia";
export const VENDOR_SONIOX = "soniox";
@@ -24,9 +25,13 @@ export const VENDOR_VOXIST = "voxist";
export const VENDOR_WHISPER = "whisper";
export const VENDOR_PLAYHT = "playht";
export const VENDOR_RIMELABS = "rimelabs";
export const VENDOR_INWORLD = "inworld";
export const VENDOR_VERBIO = "verbio";
export const VENDOR_CARTESIA = "cartesia";
export const VENDOR_OPENAI = "openai";
export const VENDOR_RESEMBLE = "resemble";
export const VENDOR_HOUNDIFY = "houndify";
export const VENDOR_GLADIA = "gladia";
export const vendors: VendorOptions[] = [
{
@@ -41,6 +46,10 @@ export const vendors: VendorOptions[] = [
name: "Deepgram",
value: VENDOR_DEEPGRAM,
},
{
name: "Deepgram Flux",
value: VENDOR_DEEPGRAM_FLUX,
},
{
name: "IBM",
value: VENDOR_IBM,
@@ -101,6 +110,10 @@ export const vendors: VendorOptions[] = [
name: "RimeLabs",
value: VENDOR_RIMELABS,
},
{
name: "Inworld",
value: VENDOR_INWORLD,
},
{
name: "Verbio",
value: VENDOR_VERBIO,
@@ -113,6 +126,18 @@ export const vendors: VendorOptions[] = [
name: "OpenAI",
value: VENDOR_OPENAI,
},
{
name: "Resemble",
value: VENDOR_RESEMBLE,
},
{
name: "SoundHound",
value: VENDOR_HOUNDIFY,
},
{
name: "Gladia",
value: VENDOR_GLADIA,
},
].sort((a, b) => a.name.localeCompare(b.name)) as VendorOptions[];
export const AWS_CREDENTIAL_ACCESS_KEY = "access_key";
@@ -145,12 +170,14 @@ export const useRegionVendors = () => {
import("./regions/ms-azure-regions"),
import("./regions/ibm-regions"),
import("./regions/speechmatics-regions"),
import("./regions/gladia-regions"),
]).then(
([
{ default: awsRegions },
{ default: msRegions },
{ default: ibmRegions },
{ default: speechmaticsRegions },
{ default: gladiaRegions },
]) => {
if (!ignore) {
setRegions({
@@ -158,6 +185,7 @@ export const useRegionVendors = () => {
microsoft: msRegions,
ibm: ibmRegions,
speechmatics: speechmaticsRegions,
gladia: gladiaRegions,
});
}
},

14
src/vendor/regions/gladia-regions.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
import type { Region } from "../types";
export const regions: Region[] = [
{
name: "US West",
value: "us-west",
},
{
name: "EU West",
value: "eu-west",
},
];
export default regions;

15
src/vendor/types.ts vendored
View File

@@ -5,6 +5,7 @@ export type Vendor =
| "WellSaid"
| "Nuance"
| "Deepgram"
| "DeepgramFlux"
| "IBM"
| "Nvidia"
| "Soniox"
@@ -17,9 +18,13 @@ export type Vendor =
| "whisper"
| "playht"
| "rimelabs"
| "inworld"
| "verbio"
| "openai"
| "Cartesia";
| "Cartesia"
| "Resemble"
| "Houndify"
| "gladia";
export interface VendorOptions {
name: Vendor;
@@ -31,6 +36,11 @@ export interface LabelOptions {
value: string;
}
export interface JambonzResourceOptions {
name: string;
value: string;
}
export interface Region {
name: string;
value: string;
@@ -76,6 +86,7 @@ export interface RegionVendors {
microsoft: Region[];
ibm: Region[];
speechmatics: Region[];
gladia: Region[];
}
export interface TtsModels {
@@ -96,6 +107,7 @@ export interface RecognizerVendors {
speechmatics: Language[];
cobalt: Language[];
assemblyai: Language[];
deepgramflux: Language[];
}
export interface SynthesisVendors {
@@ -112,6 +124,7 @@ export interface SynthesisVendors {
playht: VoiceLanguage[];
cartesia: VoiceLanguage[];
rimelabs: VoiceLanguage[];
inworld: VoiceLanguage[];
}
export interface MSRawSpeech {

View File

@@ -15,6 +15,15 @@ export default defineConfig(() => {
src: path.resolve(__dirname, "src"),
},
},
// Configure Sass to use the modern API
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler",
},
},
},
};
return config;