Compare commits

..

27 Commits

Author SHA1 Message Date
Brandon Lee Kitajchuk
5141989bb5 Add status UI to speech list (#16) 2021-06-22 10:22:51 -04:00
Dave Horton
35889ba122 fix loop in phone numbers page 2021-06-21 13:33:43 -04:00
Dave Horton
09b0bc8dde query entities by current service provider id on phone number list page 2021-06-18 13:33:43 -04:00
Dave Horton
4f1b928f8c another instance of querying gateways without voip_carrier_sid 2021-06-18 12:13:05 -04:00
Dave Horton
94c0fc88c1 bugfix: GET /SipGateways must include query arg for voip_carrier_sid 2021-06-18 10:42:05 -04:00
Dave Horton
b5a559bd08 merge of features from hosted fork (#15)
merge of recent features from hosted system
2021-06-17 22:01:04 -04:00
Dave Horton
24cb269379 Merge pull request #12 from mattddowney/master
use python3 package
2021-05-07 12:17:26 -04:00
mattddowney
c73ca9f46a use python3 package 2021-05-07 10:13:46 -06:00
Dave Horton
25a93edeac Merge pull request #10 from jambonz/ip_cidr
allow cidr ip range in sip gateway
2021-03-11 07:20:28 -05:00
Michal Tesar
7e2488a9c3 allow cidr ip range in sip gateway 2021-03-11 12:08:02 +00:00
Dave Horton
42872e9878 Merge pull request #9 from jambonz/tesarm-allow-alphanumeric-fqdn
fix - allow alphanumeric FQDN
2021-03-04 07:49:02 -05:00
Michal Tesar
809e1ae30f fix - allow alphanumeric FQDN 2021-03-04 12:34:15 +00:00
Dave Horton
04c8d05266 Merge pull request #8 from jambonz/implement-aws-speech-recognition
Implement AWS Speech Recognition
2021-01-31 14:21:07 -05:00
James Nuanez
e3d384158f Implement AWS Speech Recognition 2021-01-30 14:49:57 -08:00
Dave Horton
01c0aa321e fix react base url composition in entrypoint.sh 2021-01-12 09:44:39 -05:00
Dave Horton
30bcf9414f Merge pull request #7 from jambonz/sip_trunk_register_2
SIP Trunk registration: separate user/pass from sip realm
2021-01-12 07:51:13 -05:00
James Nuanez
fb2880b465 SIP Trunk registration: separate user/pass from sip realm 2021-01-11 19:55:15 -08:00
Dave Horton
f5f92e58e1 Merge pull request #5 from radicaldrew/master
created Dockerfile and entrypoint with default http port
2021-01-08 21:16:36 -05:00
Dave Horton
33734c91f6 Merge pull request #6 from jambonz/dependabot/npm_and_yarn/axios-0.21.1
Bump axios from 0.19.2 to 0.21.1
2021-01-05 20:00:31 -05:00
dependabot[bot]
478949ef73 Bump axios from 0.19.2 to 0.21.1
Bumps [axios](https://github.com/axios/axios) from 0.19.2 to 0.21.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v0.21.1/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.19.2...v0.21.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-01-06 00:52:47 +00:00
Andrew Karp
3ede7d5077 created Dockerfile and entrypoint with default http port 2020-12-28 12:02:26 +02:00
Dave Horton
d36a291543 Merge pull request #4 from jambonz/sip_trunk_register
SIP trunk registration
2020-12-12 12:17:34 -05:00
James Nuanez
630b555fe7 Add support for SIP trunks that require registration 2020-12-12 08:42:23 -08:00
James Nuanez
f16063ba44 Code clean-up 2020-12-12 08:06:11 -08:00
Dave Horton
b4de0ba13f Merge pull request #3 from jambonz/messaging_webhook
Implement messaging webhook on add/edit application form
2020-10-04 14:16:46 -04:00
James Nuanez
60e9792726 Show messaging webhook on confirmation modal when deleting an application 2020-10-03 13:56:39 -07:00
James Nuanez
6cda579198 Implement messaging webhook fields on add/edit application forms 2020-10-03 13:51:08 -07:00
48 changed files with 4894 additions and 1084 deletions

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:alpine as builder
RUN apk update && apk add --no-cache python3 make g++
WORKDIR /opt/app/
COPY package.json ./
RUN npm install
RUN npm prune
FROM node:alpine as webapp
RUN apk add curl
WORKDIR /opt/app
COPY . /opt/app
COPY --from=builder /opt/app/node_modules ./node_modules
COPY ./entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

8
entrypoint.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
PUBLIC_IPV4="$(curl --fail -qs whatismyip.akamai.com)"
API_PORT="${API_PORT:-3000}"
API_VERSION="${API_VERSION:-v1}"
echo "REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL:-http://$PUBLIC_IPV4:$API_PORT/$API_VERSION}" > /opt/app/.env
cd /opt/app/
npm run build
npm run serve

934
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,10 @@
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1",
"axios": "^0.19.2",
"antd": "^4.15.4",
"axios": "^0.21.1",
"moment": "^2.29.1",
"prop-types": "^15.7.2",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.1.2",
@@ -15,11 +18,11 @@
"styled-components": "^5.0.1"
},
"scripts": {
"start": "PORT=3001 react-scripts start",
"start": "PORT=${HTTP_PORT:-3001} react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"serve": "serve -s build -l 3001",
"serve": "serve -s build -l ${HTTP_PORT:-3001}",
"pm2": "pm2 start npm --name \"jambonz-webapp\" -- run serve",
"deploy": "npm i && npm run build && npm run pm2"
},

View File

@@ -10,16 +10,20 @@ import ConfigureSipTrunk from './components/pages/setup/ConfigureSipTrunk';
import SetupComplete from './components/pages/setup/SetupComplete';
import AccountsList from './components/pages/internal/AccountsList';
import ApplicationsList from './components/pages/internal/ApplicationsList';
import SipTrunksList from './components/pages/internal/SipTrunksList';
import CarriersList from './components/pages/internal/CarriersList';
import PhoneNumbersList from './components/pages/internal/PhoneNumbersList';
import MsTeamsTenantsList from './components/pages/internal/MsTeamsTenantsList';
import AccountsAddEdit from './components/pages/internal/AccountsAddEdit';
import ApplicationsAddEdit from './components/pages/internal/ApplicationsAddEdit';
import SipTrunksAddEdit from './components/pages/internal/SipTrunksAddEdit';
import CarriersAddEdit from './components/pages/internal/CarriersAddEdit';
import PhoneNumbersAddEdit from './components/pages/internal/PhoneNumbersAddEdit';
import MsTeamsTenantsAddEdit from './components/pages/internal/MsTeamsTenantsAddEdit';
import Settings from './components/pages/internal/Settings';
import RecentCallsList from './components/pages/internal/RecentCallsList';
import AlertsList from './components/pages/internal/AlertsList';
import InvalidRoute from './components/pages/InvalidRoute';
import SpeechServicesList from './components/pages/internal/SpeechServicesList';
import SpeechServicesAddEdit from './components/pages/internal/SpeechServicesAddEdit';
import Notification from './components/blocks/Notification';
import Nav from './components/blocks/Nav';
@@ -44,7 +48,8 @@ function App() {
<SideMenu />
<Route exact path="/internal/accounts"><AccountsList /></Route>
<Route exact path="/internal/applications"><ApplicationsList /></Route>
<Route exact path="/internal/sip-trunks"><SipTrunksList /></Route>
<Route exact path="/internal/carriers"><CarriersList /></Route>
<Route exact path="/internal/speech-services"><SpeechServicesList /></Route>
<Route exact path="/internal/phone-numbers"><PhoneNumbersList /></Route>
<Route exact path="/internal/ms-teams-tenants"><MsTeamsTenantsList /></Route>
@@ -63,10 +68,17 @@ function App() {
</Route>
<Route exact path={[
"/internal/sip-trunks/add",
"/internal/sip-trunks/:voip_carrier_sid/edit"
"/internal/carriers/add",
"/internal/carriers/:voip_carrier_sid/edit"
]}>
<SipTrunksAddEdit />
<CarriersAddEdit />
</Route>
<Route exact path={[
"/internal/speech-services/add",
"/internal/speech-services/:speech_service_sid/edit"
]}>
<SpeechServicesAddEdit />
</Route>
<Route exact path={[
@@ -84,6 +96,9 @@ function App() {
</Route>
<Route exact path="/internal/settings"><Settings /></Route>
<Route exact path="/internal/recent-calls"><RecentCallsList /></Route>
<Route exact path="/internal/alerts"><AlertsList /></Route>
</div>
</Route>

View File

@@ -0,0 +1,89 @@
import React from "react";
import styled from "styled-components/macro";
import PropTypes from "prop-types";
import Loader from "../../components/blocks/Loader";
import Table from "antd/lib/table";
const StyledTable = styled(Table)`
width: 100%;
margin-top: 1rem !important;
table {
border-top: 1px solid #e0e0e0;
tr,
th,
td {
border-bottom: 1px solid #e0e0e0;
font-size: 16px;
}
th,
td {
padding: 0.5rem 2rem;
}
}
.ant-pagination {
height: 32px;
.ant-pagination-simple-pager {
height: 32px;
}
}
.ant-pagination-item {
border: none;
}
`;
const StyledLoader = styled.div`
height: 100%;
width: 100%;
position: relative !important;
top: 0 !important;
left: 0 !important;
display: flex;
align-items: center;
justify-content: center;
`;
const AntdTable = ({ dataSource, columns, loading, ...rest }) => {
let props = {
...rest,
dataSource,
columns,
};
if (loading) {
props = {
...props,
loading: {
spinning: true,
indicator: (
<StyledLoader>
<Loader />
</StyledLoader>
),
},
};
}
return <StyledTable {...props} />;
};
AntdTable.propTypes = {
dataSource: PropTypes.array,
loading: PropTypes.bool,
columns: PropTypes.array,
};
AntdTable.defaultProps = {
dataSource: [],
loading: false,
columns: [],
};
export default AntdTable;

View File

@@ -25,6 +25,8 @@ const ModalContainer = styled.div`
padding: 2rem;
border-radius: 0.5rem;
background: #FFF;
text-align: left;
& h1 {
margin-top: 0;
font-size: 1.25rem;

View File

@@ -1,8 +1,21 @@
import React, { useContext } from 'react';
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useContext, useEffect, useState, useRef } from 'react';
import axios from 'axios';
import { useHistory, useLocation } from 'react-router-dom';
import styled from 'styled-components/macro';
import { NotificationDispatchContext } from '../../contexts/NotificationContext';
import Button from '../elements/Button';
import Label from '../elements/Label';
import Select from '../elements/Select';
import Form from '../elements/Form';
import Input from '../elements/Input';
import Modal from '../blocks/Modal';
import FormError from '../blocks/FormError';
import handleErrors from "../../helpers/handleErrors";
import { Link as ReactRouterLink } from 'react-router-dom';
import { ServiceProviderValueContext, ServiceProviderMethodContext } from '../../contexts/ServiceProviderContext';
import LogoJambong from "../../images/LogoJambong.svg";
import AddModalButton from '../elements/AddModalButton';
const StyledNav = styled.nav`
position: relative;
@@ -14,13 +27,6 @@ const StyledNav = styled.nav`
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.12);
`;
const NavH1 = styled.h1`
margin: 1.25rem 0 1.25rem 2rem;
font-size: 1.5rem;
font-weight: normal;
line-height: 1em;
`;
const LogOutContainer = styled.div`
margin-right: 3rem;
@media (max-width: 34rem) {
@@ -28,10 +34,53 @@ const LogOutContainer = styled.div`
}
`;
const StyledLink = styled(ReactRouterLink)`
text-decoration: none;
margin: 0 0 0 2rem;
height: 64px;
display: flex;
align-items: center;
`;
const StyledForm = styled(Form)`
position: absolute;
display: flex;
justify-content: center;
align-items: center;
left: 50%;
transform: translate(-50%, 0);
`;
const StyledLabel = styled(Label)`
margin-right: 1rem;
`;
const ModalContainer = styled.div`
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
padding: 1rem;
width: 500;
`;
const StyledFormError = styled(FormError)`
margin-top: 1rem;
`;
const Nav = () => {
const history = useHistory();
const location = useLocation();
const dispatch = useContext(NotificationDispatchContext);
const currentServiceProvider = useContext(ServiceProviderValueContext);
const setCurrentServiceProvider = useContext(ServiceProviderMethodContext);
const [serviceProviders, setServiceProviders] = useState([]);
const [showServiceProviderModal, setShowServiceProviderModal] = useState(false);
const [showModalLoader, setShowModalLoader] = useState(false);
const [serviceProviderName, setServiceProviderName] = useState("");
const [serviceProviderInvalid, setServiceProviderInvalid] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const refServiceProvider = useRef(null);
const logOut = () => {
localStorage.removeItem('token');
@@ -44,9 +93,112 @@ const Nav = () => {
});
};
const onChangeServiceProvider = (sp) => {
if (sp === "add") {
setShowServiceProviderModal(true);
} else {
setCurrentServiceProvider(sp);
}
};
const getServiceProviders = async () => {
const jwt = localStorage.getItem('token');
if (history.location.pathname !== '' && jwt) {
const serviceProvidersResponse = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/ServiceProviders',
headers: {
Authorization: `Bearer ${jwt}`,
},
});
setServiceProviders(
(serviceProvidersResponse.data || []).sort(
(a, b) => a.name.localeCompare(b.name)
)
);
const isExisted = serviceProvidersResponse.data.find(item => item.service_provider_sid === currentServiceProvider);
if (!isExisted) {
setCurrentServiceProvider(serviceProvidersResponse.data[0].service_provider_sid);
}
}
};
const handleAddServiceProvider = async () => {
if (serviceProviderName) {
setServiceProviderInvalid(false);
setErrorMessage("");
try {
setShowModalLoader(true);
const jwt = localStorage.getItem('token');
const serviceProviderResponse = await axios({
method: 'post',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/ServiceProviders`,
headers: {
Authorization: `Bearer ${jwt}`,
},
data: {
name: serviceProviderName,
},
});
setCurrentServiceProvider(serviceProviderResponse.data.sid);
getServiceProviders();
setShowServiceProviderModal(false);
} catch (err) {
handleErrors({ err, history, dispatch, setErrorMessage });
} finally {
setShowModalLoader(false);
}
} else {
setServiceProviderInvalid(true);
setErrorMessage("Please enter a name for Service Provider");
if (refServiceProvider && refServiceProvider.current) {
refServiceProvider.current.focus();
}
}
};
useEffect(() => {
getServiceProviders();
}, [history.location.pathname]);
return (
<StyledNav>
<NavH1>Jambonz</NavH1>
<StyledLink to="/internal/accounts">
<img src={LogoJambong} alt="link-img" />
</StyledLink>
{location.pathname !== '/' && (
<StyledForm>
<StyledLabel htmlFor="serviceProvider">Service Provider:</StyledLabel>
<Select
name="serviceProvider"
id="serviceProvider"
value={currentServiceProvider}
onChange={e => onChangeServiceProvider(e.target.value)}
>
{serviceProviders.map(a => (
<option
key={a.service_provider_sid}
value={a.service_provider_sid}
>
{a.name}
</option>
))}
</Select>
<AddModalButton
addButtonText="Add Service Provider"
onClick={()=>setShowServiceProviderModal(true)}
/>
</StyledForm>
)}
{location.pathname !== '/' && (
<LogOutContainer>
<Button
@@ -59,6 +211,37 @@ const Nav = () => {
</Button>
</LogOutContainer>
)}
{showServiceProviderModal && (
<Modal
title="Add New Service Provider"
loader={showModalLoader}
closeText="Close"
actionText="Add"
handleCancel={() => {
setServiceProviderName("");
setShowServiceProviderModal(false);
}}
handleSubmit={handleAddServiceProvider}
content={
<ModalContainer>
<StyledLabel htmlFor="name">Name:</StyledLabel>
<Input
name="name"
id="name"
value={serviceProviderName}
onChange={e => setServiceProviderName(e.target.value)}
placeholder="Service provider name"
invalid={serviceProviderInvalid}
autoFocus
ref={refServiceProvider}
/>
{errorMessage && (
<StyledFormError grid message={errorMessage} />
)}
</ModalContainer>
}
/>
)}
</StyledNav>
);
};

View File

@@ -3,6 +3,7 @@ import axios from 'axios';
import styled from 'styled-components';
import { useHistory } from 'react-router-dom';
import { NotificationDispatchContext } from '../../contexts/NotificationContext';
import { ServiceProviderValueContext } from '../../contexts/ServiceProviderContext';
const Container = styled.div`
margin-top: 0.25rem;
@@ -20,6 +21,7 @@ const Container = styled.div`
const Sbcs = props => {
let history = useHistory();
const dispatch = useContext(NotificationDispatchContext);
const currentServiceProvider = useContext(ServiceProviderValueContext);
const [ sbcs, setSbcs ] = useState('');
useEffect(() => {
const getAPIData = async () => {
@@ -36,7 +38,7 @@ const Sbcs = props => {
const sbcResults = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/Sbcs',
url: `/Sbcs?service_provider_sid=${currentServiceProvider}`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},

View File

@@ -5,10 +5,13 @@ import { ModalStateContext } from '../../contexts/ModalContext';
import { ShowMsTeamsStateContext, ShowMsTeamsDispatchContext } from '../../contexts/ShowMsTeamsContext';
import { ReactComponent as AccountsIcon } from '../../images/AccountsIcon.svg';
import { ReactComponent as ApplicationsIcon } from '../../images/ApplicationsIcon.svg';
import { ReactComponent as SipTrunksIcon } from '../../images/SipTrunksIcon.svg';
import { ReactComponent as CarriersIcon } from '../../images/CarriersIcon.svg';
import { ReactComponent as PhoneNumbersIcon } from '../../images/PhoneNumbersIcon.svg';
import { ReactComponent as MsTeamsIcon } from '../../images/MsTeamsIcon.svg';
import { ReactComponent as SettingsIcon } from '../../images/SettingsIcon.svg';
import { ReactComponent as RecentCallsIcon } from '../../images/RecentCallsIcon.svg';
import { ReactComponent as AlertsIcon } from '../../images/AlertsIcon.svg';
import { ReactComponent as SpeechIcon } from '../../images/SpeechIcon.svg';
const StyledSideMenu = styled.div`
width: 15rem;
@@ -32,14 +35,12 @@ const StyledNavLink = styled(NavLink).attrs({ activeClassName })`
fill: #565656;
&.${activeClassName} {
box-shadow: inset 3px 0 0 0 #D91C5C;
color: #D91C5C;
fill: #D91C5C;
}
&:focus {
outline: 0;
box-shadow: inset 0 0 0 3px #D91C5C;
}
&:hover {
@@ -68,6 +69,13 @@ const MenuText = styled.span`
outline: 0;
`;
const StyledH2 = styled.h2`
margin: 3rem 0 1rem 0.75rem;
font-size: 1rem;
font-weight: 500;
color: #757575;
`;
const MenuLink = props => {
const modalOpen = useContext(ModalStateContext);
return (
@@ -95,14 +103,18 @@ const SideMenu = () => {
}, []);
return (
<StyledSideMenu>
<MenuLink to="/internal/settings" name="Settings" icon={<SettingsIcon />} />
<MenuLink to="/internal/accounts" name="Accounts" icon={<AccountsIcon />} />
<MenuLink to="/internal/applications" name="Applications" icon={<ApplicationsIcon />} />
<MenuLink to="/internal/sip-trunks" name="SIP Trunks" icon={<SipTrunksIcon />} />
<MenuLink to="/internal/recent-calls" name="Recent Calls" icon={<RecentCallsIcon />} />
<MenuLink to="/internal/alerts" name="Alerts" icon={<AlertsIcon />} />
<StyledH2>Bring Your Own Services</StyledH2>
<MenuLink to="/internal/carriers" name="Carriers" icon={<CarriersIcon />} />
<MenuLink to="/internal/speech-services" name="Speech" icon={<SpeechIcon />} />
<MenuLink to="/internal/phone-numbers" name="Phone Numbers" icon={<PhoneNumbersIcon />} />
{showMsTeams && (
<MenuLink to="/internal/ms-teams-tenants" name="MS Teams Tenants" icon={<MsTeamsIcon />} />
)}
<MenuLink to="/internal/settings" name="Settings" icon={<SettingsIcon />} />
</StyledSideMenu>
);
};

View File

@@ -12,6 +12,8 @@ import Modal from '../blocks/Modal.js';
import FormError from '../blocks/FormError.js';
import CopyableText from '../elements/CopyableText';
import ToggleText from '../blocks/ToggleText.js';
import { ReactComponent as CheckGreen } from '../../images/CheckGreen.svg';
import { ReactComponent as ErrorIcon } from '../../images/ErrorIcon.svg';
const Td = styled.td`
padding: 0.5rem 0;
@@ -91,7 +93,7 @@ const TableContent = props => {
};
getNewContent();
// eslint-disable-next-line
}, []);
}, [props.getContent]);
//=============================================================================
// Handle checkboxes
@@ -389,6 +391,11 @@ const TableContent = props => {
columnTitle = columnContent;
} else if (a[c.key].type === 'masked') {
columnContent = <ToggleText masked={a[c.key].masked} revealed={a[c.key].revealed} />;
} else if (a[c.key].type === 'status') {
columnContent = a[c.key].content === 'ok' ? <CheckGreen />
: a[c.key].content === 'fail' ? <ErrorIcon />
: a[c.key].content;
columnTitle = a[c.key].title;
}
} else {
columnContent = a[c.key];

View File

@@ -70,6 +70,7 @@ const TableMenu = props => (
selected={props.open}
disabled={props.disabled}
onClick={e => {
e.preventDefault();
e.stopPropagation();
props.handleMenuOpen(props.sid);
}}

View File

@@ -51,6 +51,7 @@ const StyledLink = styled(FilteredLink)`
const Tooltip = styled.span`
display: none;
color: #767676;
a:focus > &,
a:hover > & {
display: inline;

View File

@@ -0,0 +1,84 @@
import React, { useContext } from 'react';
import { Link, useHistory } from 'react-router-dom';
import styled from 'styled-components/macro';
import { ModalStateContext } from '../../contexts/ModalContext';
const FilteredLink = ({ addButtonText, ...props }) => (
<Link {...props}>{props.children}</Link>
);
const StyledLink = styled(FilteredLink)`
display: flex;
padding: 0;
border: 0;
outline: 0;
background: none;
cursor: pointer;
grid-column: 2;
border-radius: 50%;
text-decoration: none;
color: #565656;
margin-left: 1rem;
position: relative;
& > span:first-child {
display: flex;
justify-content: center;
align-items: center;
height: 2rem;
width: 2rem;
border-radius: 50%;
outline: 0;
background: #D91C5C;
color: #FFF;
font-size: 2.5rem;
box-shadow: 0 0.375rem 0.25rem rgba(0, 0, 0, 0.12),
0 0 0.25rem rgba(0, 0, 0, 0.18);
}
&:focus > span:first-child {
border: 0.25rem solid #890934;
}
&:hover > span:first-child {
}
&:active > span:first-child {
}
`;
const Tooltip = styled.span`
display: none;
color: #767676;
a:focus > &,
a:hover > & {
display: inline;
position: absolute;
white-space: nowrap;
left: calc(100% + 0.75rem);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: #FFF;
box-shadow: 0 0.375rem 0.25rem rgba(0, 0, 0, 0.12),
0 0 0.25rem rgba(0, 0, 0, 0.18);
z-index: 60;
}
`;
const AddModalButton = props => {
const modalOpen = useContext(ModalStateContext);
const history = useHistory();
return (
<StyledLink
{...props}
to={history.location.pathname}
tabIndex={modalOpen ? '-1' : ''}
>
<span tabIndex="-1">
+
</span>
<Tooltip>{props.addButtonText}</Tooltip>
</StyledLink>
);
};
export default AddModalButton;

View File

@@ -0,0 +1,14 @@
import styled from 'styled-components/macro';
const Code = styled.code`
white-space: pre-wrap;
text-align: left;
background: #fbfbfb;
padding: 0.5rem;
box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #B6B6B6;
max-height: 120px;
overflow: auto;
`;
export default Code;

View File

@@ -0,0 +1,65 @@
import React, { forwardRef } from 'react';
import styled from 'styled-components/macro';
const Container = styled.div`
position: relative;
height: 2.25rem;
`;
const StyledInput = styled.input`
clip: rect(1px, 1px, 1px, 1px);
height: 1px;
width: 1px;
border: 0;
margin: -1px;
padding: 0;
outline: none;
white-space: nowrap;
overflow: hidden;
&:after {
content: '${props => props.validFile ? 'Choose a Different File' : 'Choose File'}';
position: absolute;
display: flex;
justify-content: center;
align-items: center;
height: 2.25rem;
top: 0;
left: 0;
padding: 0 1rem;
border-radius: 0.25rem;
background: #D91C5C;
color: #FFF;
font-weight: 500;
cursor: pointer;
}
&:focus:after {
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.12),
inset 0 0 0 0.25rem #890934;
}
&:hover:not([disabled]):after {
background: #BD164E;
}
&:active:not([disabled]):after {
background: #A40D40;
}
`;
const FileUpload = (props, ref) => {
return (
<Container>
<StyledInput
id={props.id}
name={props.id}
type="file"
onChange={props.onChange}
validFile={props.validFile}
/>
</Container>
);
};
export default forwardRef(FileUpload);

View File

@@ -24,6 +24,7 @@ const StyledReactRouterLink = styled(FilteredLink)`
align-items: center;
position: relative;
outline: 0;
color: #D91C5C;
}
&:focus > span {
@@ -31,14 +32,18 @@ const StyledReactRouterLink = styled(FilteredLink)`
margin: -0.25rem;
border-radius: 0.25rem;
box-shadow: 0 0 0 0.125rem #D91C5C;
color: #D91C5C;
}
&:hover > span {
box-shadow: 0 0.125rem 0 #D91C5C;
border-radius: 0;
color: #D91C5C;
}
&:active > span {}
&:active > span {
color: #D91C5C;
}
${props => props.formLink && `
grid-column: 2;

View File

@@ -0,0 +1,119 @@
import React, { useRef, forwardRef, useImperativeHandle } from 'react';
import styled from 'styled-components/macro';
import Label from './Label';
import Tooltip from './Tooltip';
const RadioContainer = styled.div`
margin-left: ${props => props.noLeftMargin
? '-0.5rem'
: '0.5rem'
};
position: relative;
display: flex;
align-items: center;
height: 2.25rem;
padding: 0 0.5rem;
border: 1px solid transparent;
border-radius: 0.125rem;
${props => props.invalid && `
border-color: #D91C5C;
background: RGBA(217,28,92,0.2);
`}
`;
const StyledRadio = styled.input`
outline: none;
margin: 0.25rem;
width: 1rem;
height: 1rem;
`;
const StyledLabel = styled(Label)`
padding-left: 0.5rem;
cursor: ${props => props.disabled
? 'not-allowed'
: 'pointer'
};
${props => props.tooltip && `
& > span {
border-bottom: 1px dotted;
border-left: 1px solid transparent;
cursor: help;
}
`}
&::before {
content: '';
position: absolute;
top: 0.375rem;
left: 0.5rem;
width: 1.5rem;
height: 1.5rem;
border: 1px solid #A5A5A5;
border-radius: 50%;
background: #FFF;
}
input:focus + &::before {
border-color: #565656;
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.12);
}
input:checked + &::after {
content: '';
position: absolute;
top: 10px;
left: 0.75rem;
height: 1rem;
width: 1rem;
border-radius: 50%;
background: ${props => props.disabled
? '#959595'
: '#707070'
};
}
`;
const Radio = (props, ref) => {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return (
<RadioContainer
invalid={props.invalid}
noLeftMargin={props.noLeftMargin}
>
<StyledRadio
name={props.name}
id={props.id}
value={props.id}
type="radio"
checked={props.checked}
onChange={props.onChange}
ref={inputRef}
disabled={props.disabled}
/>
<StyledLabel
htmlFor={props.id}
tooltip={props.tooltip}
invalid={props.invalid}
disabled={props.disabled}
>
<span>
{props.label}
{
props.tooltip &&
<Tooltip>
{props.tooltip}
</Tooltip>
}
</span>
</StyledLabel>
</RadioContainer>
);
};
export default forwardRef(Radio);

View File

@@ -9,15 +9,46 @@ import Select from '../elements/Select';
import InputGroup from '../elements/InputGroup';
import PasswordInput from '../elements/PasswordInput';
import FormError from '../blocks/FormError';
import TableMenu from '../blocks/TableMenu';
import Loader from '../blocks/Loader';
import Modal from '../blocks/Modal';
import Button from '../elements/Button';
import Link from '../elements/Link';
import Tooltip from '../elements/Tooltip';
import CopyableText from '../elements/CopyableText';
import handleErrors from "../../helpers/handleErrors";
import styled from 'styled-components/macro';
const StyledInputGroup = styled(InputGroup)`
position: relative;
display: grid;
grid-template-columns: 1fr auto;
& > label {
text-align: left;
}
& > div:last-child {
margin-top: -24px;
}
`;
const ModalContainer = styled.div`
margin-top: 2rem;
`;
const P = styled.p`
margin: 0 0 1.5rem;
font-size: 14px;
font-weight: 500;
font-weight: 500;
color: #231f20;
`;
const AccountForm = props => {
let history = useHistory();
const dispatch = useContext(NotificationDispatchContext);
const jwt = localStorage.getItem("token");
// Refs
const refName = useRef(null);
@@ -34,6 +65,7 @@ const AccountForm = props => {
const [ method, setMethod ] = useState('POST');
const [ user, setUser ] = useState('' || '');
const [ password, setPassword ] = useState('' || '');
const [ webhookSecret, setWebhookSecret ] = useState('');
// Invalid form inputs
const [ invalidName, setInvalidName ] = useState(false);
@@ -53,10 +85,76 @@ const AccountForm = props => {
const [ serviceProviderSid, setServiceProviderSid ] = useState('');
const [ accountApplications, setAccountApplications ] = useState([]);
const [ menuOpen, setMenuOpen ] = useState(null);
const [showConfirmSecret, setShowConfirmSecret] = useState(false);
const [generatingSecret, setGeneratingSecret] = useState(false);
const handleMenuOpen = sid => {
if (menuOpen === sid) {
setMenuOpen(null);
} else {
setMenuOpen(sid);
}
};
const copyWebhookSecret = async e => {
e.preventDefault();
setMenuOpen(null);
try {
await navigator.clipboard.writeText(webhookSecret);
dispatch({
type: 'ADD',
level: 'success',
message: `Webhook Secret copied to clipboard`,
});
} catch (err) {
dispatch({
type: 'ADD',
level: 'error',
message: `Unable to copy Webhook Secret.`,
});
}
};
const generateWebhookSecret = async e => {
e.preventDefault();
setShowConfirmSecret(true);
setMenuOpen(null);
};
const updateWebhookSecret = async () => {
try {
setGeneratingSecret(true);
const apiKeyResponse = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/Accounts/${accountSid}/WebhookSecret?regenerate=true`,
headers: {
Authorization: `Bearer ${jwt}`,
},
});
if (apiKeyResponse.status === 200) {
setWebhookSecret(apiKeyResponse.data.webhook_secret);
dispatch({
type: 'ADD',
level: 'success',
message: 'Webhook signing secret was successfully generated.',
});
}
} catch (err) {
handleErrors({ err, history, dispatch });
} finally {
setGeneratingSecret(false);
setShowConfirmSecret(false);
}
};
useEffect(() => {
const getAccounts = async () => {
try {
if (!localStorage.getItem('token')) {
if (!jwt) {
history.push('/');
dispatch({
type: 'ADD',
@@ -72,7 +170,7 @@ const AccountForm = props => {
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/Accounts',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
Authorization: `Bearer ${jwt}`,
},
});
promiseList.push(accountsPromise);
@@ -83,7 +181,7 @@ const AccountForm = props => {
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/Applications',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
Authorization: `Bearer ${jwt}`,
},
});
promiseList.push(applicationsPromise);
@@ -95,7 +193,7 @@ const AccountForm = props => {
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/ServiceProviders',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
Authorization: `Bearer ${jwt}`,
},
});
promiseList.push(serviceProvidersPromise);
@@ -155,6 +253,7 @@ const AccountForm = props => {
setMethod((acc.registration_hook && acc.registration_hook.method ) || 'post');
setUser((acc.registration_hook && acc.registration_hook.username) || '');
setPassword((acc.registration_hook && acc.registration_hook.password) || '');
setWebhookSecret(acc.webhook_secret || '');
if (
(acc.registration_hook && acc.registration_hook.username) ||
@@ -274,6 +373,7 @@ const AccountForm = props => {
username: user.trim() || null,
password: password || null,
},
webhook_secret: webhookSecret || null,
};
if (props.type === 'add') {
@@ -293,7 +393,7 @@ const AccountForm = props => {
baseURL: process.env.REACT_APP_API_BASE_URL,
url,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
Authorization: `Bearer ${jwt}`,
},
data: axiosData,
});
@@ -337,6 +437,19 @@ const AccountForm = props => {
}
};
const menuItems = [
{
type: 'button',
name: 'Copy',
action: copyWebhookSecret,
},
{
type: 'button',
name: 'Generate new secret',
action: generateWebhookSecret,
},
];
return (
showLoader
? <Loader
@@ -390,6 +503,16 @@ const AccountForm = props => {
autoFocus={props.type === 'setup'}
ref={refSipRealm}
/>
<Label htmlFor="webhookSecret">Webhook Secret</Label>
<StyledInputGroup>
<Label>{webhookSecret || "None"}</Label>
<TableMenu
sid="webhook"
open={menuOpen === "webhook"}
handleMenuOpen={handleMenuOpen}
menuItems={webhookSecret ? menuItems: menuItems.slice(1)}
/>
</StyledInputGroup>
{props.type === 'edit' && (
<React.Fragment>
@@ -536,6 +659,23 @@ const AccountForm = props => {
Skip for now &mdash; I'll complete later
</Link>
)}
{showConfirmSecret && (
<Modal
title={generatingSecret ? "" : "Generate new secret"}
loader={generatingSecret}
hideButtons={generatingSecret}
maskClosable={!generatingSecret}
actionText="OK"
content={
<ModalContainer>
<P>Press OK to generate a new webhook signing secret.</P>
<P>Note: this will immediately invalidate the old webhook signing secret.</P>
</ModalContainer>
}
handleCancel={() => setShowConfirmSecret(false)}
handleSubmit={updateWebhookSecret}
/>
)}
</Form>
);
};

View File

@@ -13,6 +13,7 @@ import Button from '../elements/Button';
import SpeechSynthesisLanguageGoogle from '../../data/SpeechSynthesisLanguageGoogle';
import SpeechSynthesisLanguageAws from '../../data/SpeechSynthesisLanguageAws';
import SpeechRecognizerLanguageGoogle from '../../data/SpeechRecognizerLanguageGoogle';
import SpeechRecognizerLanguageAws from '../../data/SpeechRecognizerLanguageAws';
import Loader from '../blocks/Loader';
import CopyableText from '../elements/CopyableText';
@@ -29,6 +30,8 @@ const ApplicationForm = props => {
const refStatusWebhook = useRef(null);
const refStatusWebhookUser = useRef(null);
const refStatusWebhookPass = useRef(null);
const refMessagingWebhookUser = useRef(null);
const refMessagingWebhookPass = useRef(null);
// Form inputs
const [ name, setName ] = useState('');
@@ -41,6 +44,10 @@ const ApplicationForm = props => {
const [ statusWebhookMethod, setStatusWebhookMethod ] = useState('POST');
const [ statusWebhookUser, setStatusWebhookUser ] = useState('');
const [ statusWebhookPass, setStatusWebhookPass ] = useState('');
const [ messagingWebhook, setMessagingWebhook ] = useState('');
const [ messagingWebhookMethod, setMessagingWebhookMethod ] = useState('POST');
const [ messagingWebhookUser, setMessagingWebhookUser ] = useState('');
const [ messagingWebhookPass, setMessagingWebhookPass ] = useState('');
const [ speechSynthesisVendor, setSpeechSynthesisVendor ] = useState('google');
const [ speechSynthesisLanguage, setSpeechSynthesisLanguage ] = useState('en-US');
const [ speechSynthesisVoice, setSpeechSynthesisVoice ] = useState('en-US-Standard-C');
@@ -48,14 +55,16 @@ const ApplicationForm = props => {
const [ speechRecognizerLanguage, setSpeechRecognizerLanguage ] = useState('en-US');
// Invalid form inputs
const [ invalidName, setInvalidName ] = useState(false);
const [ invalidAccount, setInvalidAccount ] = useState(false);
const [ invalidCallWebhook, setInvalidCallWebhook ] = useState(false);
const [ invalidCallWebhookUser, setInvalidCallWebhookUser ] = useState(false);
const [ invalidCallWebhookPass, setInvalidCallWebhookPass ] = useState(false);
const [ invalidStatusWebhook, setInvalidStatusWebhook ] = useState(false);
const [ invalidStatusWebhookUser, setInvalidStatusWebhookUser ] = useState(false);
const [ invalidStatusWebhookPass, setInvalidStatusWebhookPass ] = useState(false);
const [ invalidName, setInvalidName ] = useState(false);
const [ invalidAccount, setInvalidAccount ] = useState(false);
const [ invalidCallWebhook, setInvalidCallWebhook ] = useState(false);
const [ invalidCallWebhookUser, setInvalidCallWebhookUser ] = useState(false);
const [ invalidCallWebhookPass, setInvalidCallWebhookPass ] = useState(false);
const [ invalidStatusWebhook, setInvalidStatusWebhook ] = useState(false);
const [ invalidStatusWebhookUser, setInvalidStatusWebhookUser ] = useState(false);
const [ invalidStatusWebhookPass, setInvalidStatusWebhookPass ] = useState(false);
const [ invalidMessagingWebhookUser, setInvalidMessagingWebhookUser ] = useState(false);
const [ invalidMessagingWebhookPass, setInvalidMessagingWebhookPass ] = useState(false);
const [ showLoader, setShowLoader ] = useState(true);
const [ errorMessage, setErrorMessage ] = useState('');
@@ -66,6 +75,9 @@ const ApplicationForm = props => {
const [ showStatusAuth, setShowStatusAuth ] = useState(false);
const toggleStatusAuth = () => setShowStatusAuth(!showStatusAuth);
const [ showMessagingAuth, setShowMessagingAuth ] = useState(false);
const toggleMessagingAuth = () => setShowMessagingAuth(!showMessagingAuth);
const [ accounts, setAccounts ] = useState([]);
const [ applications, setApplications ] = useState([]);
const [ applicationSid, setApplicationSid ] = useState([]);
@@ -167,6 +179,10 @@ const ApplicationForm = props => {
setStatusWebhookMethod( (app.call_status_hook && app.call_status_hook.method) || 'post');
setStatusWebhookUser( (app.call_status_hook && app.call_status_hook.username) || '');
setStatusWebhookPass( (app.call_status_hook && app.call_status_hook.password) || '');
setMessagingWebhook( (app.messaging_hook && app.messaging_hook.url) || '');
setMessagingWebhookMethod( (app.messaging_hook && app.messaging_hook.method) || 'post');
setMessagingWebhookUser( (app.messaging_hook && app.messaging_hook.username) || '');
setMessagingWebhookPass( (app.messaging_hook && app.messaging_hook.password) || '');
setSpeechSynthesisVendor( app.speech_synthesis_vendor || '');
setSpeechSynthesisLanguage( app.speech_synthesis_language || '');
setSpeechSynthesisVoice( app.speech_synthesis_voice || '');
@@ -187,6 +203,13 @@ const ApplicationForm = props => {
) {
setShowStatusAuth(true);
}
if (
(app.messaging_hook && app.messaging_hook.username) ||
(app.messaging_hook && app.messaging_hook.password)
) {
setShowMessagingAuth(true);
}
}
}
setShowLoader(false);
@@ -230,6 +253,8 @@ const ApplicationForm = props => {
setInvalidStatusWebhook(false);
setInvalidStatusWebhookUser(false);
setInvalidStatusWebhookPass(false);
setInvalidMessagingWebhookUser(false);
setInvalidMessagingWebhookPass(false);
let errorMessages = [];
let focusHasBeenSet = false;
@@ -299,6 +324,20 @@ const ApplicationForm = props => {
}
}
if ((messagingWebhookUser && !messagingWebhookPass) || (!messagingWebhookUser && messagingWebhookPass)) {
errorMessages.push('Messaging Webhook username and password must be either both filled out or both empty.');
setInvalidMessagingWebhookUser(true);
setInvalidMessagingWebhookPass(true);
if (!focusHasBeenSet) {
if (!messagingWebhookUser) {
refMessagingWebhookUser.current.focus();
} else {
refMessagingWebhookPass.current.focus();
}
focusHasBeenSet = true;
}
}
if (errorMessages.length > 1) {
setErrorMessage(errorMessages);
return;
@@ -335,6 +374,12 @@ const ApplicationForm = props => {
username: statusWebhookUser.trim() || null,
password: statusWebhookPass || null,
},
messaging_hook: {
url: messagingWebhook.trim(),
method: messagingWebhookMethod,
username: messagingWebhookUser.trim() || null,
password: messagingWebhookPass || null,
},
speech_synthesis_vendor: speechSynthesisVendor,
speech_synthesis_language: speechSynthesisLanguage,
speech_synthesis_voice: speechSynthesisVoice,
@@ -604,6 +649,75 @@ const ApplicationForm = props => {
<hr />
<Label htmlFor="messagingWebhook">Messaging Webhook</Label>
<InputGroup>
<Input
large={props.type === 'setup'}
name="messagingWebhook"
id="messagingWebhook"
value={messagingWebhook}
onChange={e => setMessagingWebhook(e.target.value)}
placeholder="URL for your web application that will receive SMS"
/>
<Label
middle
htmlFor="messagingWebhookMethod"
>
Method
</Label>
<Select
large={props.type === 'setup'}
name="messagingWebhookMethod"
id="messagingWebhookMethod"
value={messagingWebhookMethod}
onChange={e => setMessagingWebhookMethod(e.target.value)}
>
<option value="POST">POST</option>
<option value="GET">GET</option>
</Select>
</InputGroup>
{showMessagingAuth ? (
<InputGroup>
<Label indented htmlFor="messagingWebhookUser">User</Label>
<Input
large={props.type === 'setup'}
name="messagingWebhookUser"
id="messagingWebhookUser"
value={messagingWebhookUser}
onChange={e => setMessagingWebhookUser(e.target.value)}
placeholder="Optional"
invalid={invalidMessagingWebhookUser}
ref={refMessagingWebhookUser}
/>
<Label htmlFor="messagingWebhookPass" middle>Password</Label>
<PasswordInput
large={props.type === 'setup'}
allowShowPassword
name="messagingWebhookPass"
id="messagingWebhookPass"
password={messagingWebhookPass}
setPassword={setMessagingWebhookPass}
setErrorMessage={setErrorMessage}
placeholder="Optional"
invalid={invalidMessagingWebhookPass}
ref={refMessagingWebhookPass}
/>
</InputGroup>
) : (
<Button
text
formLink
type="button"
onClick={toggleMessagingAuth}
>
Use HTTP Basic Authentication
</Button>
)}
<hr />
<Label htmlFor="speechSynthesisVendor">Speech Synthesis Vendor</Label>
<InputGroup>
<Select
@@ -727,9 +841,25 @@ const ApplicationForm = props => {
name="speechRecognizerVendor"
id="speechRecognizerVendor"
value={speechRecognizerVendor}
onChange={e => setSpeechRecognizerVendor(e.target.value)}
onChange={e => {
setSpeechRecognizerVendor(e.target.value);
// Google and AWS have different language lists. If the newly chosen
// vendor doesn't have the same language that was already in use,
// select US English
if ((
e.target.value === 'google' &&
!SpeechRecognizerLanguageGoogle.some(l => l.code === speechRecognizerLanguage)
) || (
e.target.value === 'aws' &&
!SpeechRecognizerLanguageAws.some(l => l.code === speechRecognizerLanguage)
)) {
setSpeechRecognizerLanguage('en-US');
}
}}
>
<option value="google">Google</option>
<option value="aws">AWS</option>
</Select>
<Label middle htmlFor="speechRecognizerLanguage">Language</Label>
<Select
@@ -739,9 +869,15 @@ const ApplicationForm = props => {
value={speechRecognizerLanguage}
onChange={e => setSpeechRecognizerLanguage(e.target.value)}
>
{SpeechRecognizerLanguageGoogle.map(l => (
<option key={l.code} value={l.code}>{l.name}</option>
))}
{speechRecognizerVendor === 'google' ? (
SpeechRecognizerLanguageGoogle.map(l => (
<option key={l.code} value={l.code}>{l.name}</option>
))
) : (
SpeechRecognizerLanguageAws.map(l => (
<option key={l.code} value={l.code}>{l.name}</option>
))
)}
</Select>
</InputGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -128,7 +128,7 @@ const PhoneNumberForm = props => {
history.push('/internal/accounts');
return;
} else if (!sipTrunks.length) {
history.push('/internal/sip-trunks');
history.push('/internal/carriers');
return;
}

View File

@@ -6,35 +6,29 @@ import { ShowMsTeamsDispatchContext } from '../../contexts/ShowMsTeamsContext';
import Form from '../elements/Form';
import Input from '../elements/Input';
import Label from '../elements/Label';
import Select from '../elements/Select';
import Checkbox from '../elements/Checkbox';
import InputGroup from '../elements/InputGroup';
import PasswordInput from '../elements/PasswordInput';
import FormError from '../blocks/FormError';
import Button from '../elements/Button';
import Loader from '../blocks/Loader';
import { ServiceProviderValueContext } from '../../contexts/ServiceProviderContext';
import handleErrors from "../../helpers/handleErrors";
const SettingsForm = () => {
const history = useHistory();
const dispatch = useContext(NotificationDispatchContext);
const refreshMsTeamsData = useContext(ShowMsTeamsDispatchContext);
const currentServiceProvider = useContext(ServiceProviderValueContext);
// Refs
const refEnableMsTeams = useRef(null);
const refSbcDomainName = useRef(null);
const refSipRealm = useRef(null);
const refRegWebhook = useRef(null);
const refUser = useRef(null);
const refPassword = useRef(null);
const refServiceProviderName = useRef(null);
// Form inputs
const [ enableMsTeams, setEnableMsTeams ] = useState(false);
const [ sbcDomainName, setSbcDomainName ] = useState('');
const [ sipRealm, setSipRealm ] = useState('');
const [ regWebhook, setRegWebhook ] = useState('');
const [ method, setMethod ] = useState('POST');
const [ user, setUser ] = useState('');
const [ password, setPassword ] = useState('');
const [serviceProviderName, setServiceProviderName] = useState('');
// For when user has data in sbcDomainName and then taps the checkbox to disable MsTeams
const [ savedSbcDomainName, setSavedSbcDomainName ] = useState('');
@@ -42,17 +36,11 @@ const SettingsForm = () => {
// Invalid form inputs
const [ invalidEnableMsTeams, setInvalidEnableMsTeams ] = useState(false);
const [ invalidSbcDomainName, setInvalidSbcDomainName ] = useState(false);
const [ invalidSipRealm, setInvalidSipRealm ] = useState(false);
const [ invalidRegWebhook, setInvalidRegWebhook ] = useState(false);
const [ invalidUser, setInvalidUser ] = useState(false);
const [ invalidPassword, setInvalidPassword ] = useState(false);
const [invalidServiceProviderName, setInvalidServiceProviderName] = useState(false);
const [ showLoader, setShowLoader ] = useState(true);
const [ errorMessage, setErrorMessage ] = useState('');
const [ showAuth, setShowAuth ] = useState(false);
const toggleAuth = () => setShowAuth(!showAuth);
const [ serviceProviderSid, setServiceProviderSid ] = useState('');
useEffect(() => {
@@ -71,56 +59,30 @@ const SettingsForm = () => {
const serviceProvidersResponse = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/ServiceProviders',
url: `/ServiceProviders/${currentServiceProvider}`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
const sp = serviceProvidersResponse.data[0];
const sp = serviceProvidersResponse.data;
setServiceProviderName(sp.name || '');
setServiceProviderSid(sp.service_provider_sid || '');
setEnableMsTeams(sp.ms_teams_fqdn ? true : false);
setSbcDomainName(sp.ms_teams_fqdn || '');
setSipRealm(sp.root_domain || '');
setRegWebhook((sp.registration_hook && sp.registration_hook.url) || '');
setMethod((sp.registration_hook && sp.registration_hook.method) || 'post');
setUser((sp.registration_hook && sp.registration_hook.username) || '');
setPassword((sp.registration_hook && sp.registration_hook.password) || '');
if (
(sp.registration_hook && sp.registration_hook.username) ||
(sp.registration_hook && sp.registration_hook.password)
) {
setShowAuth(true);
}
setShowLoader(false);
} catch (err) {
if (err.response && err.response.status === 401) {
localStorage.removeItem('token');
sessionStorage.clear();
history.push('/');
dispatch({
type: 'ADD',
level: 'error',
message: 'Your session has expired. Please log in and try again.',
});
} else {
dispatch({
type: 'ADD',
level: 'error',
message: (err.response && err.response.data && err.response.data.msg) || 'Something went wrong, please try again.',
});
console.log(err.response || err);
}
handleErrors({ err, history, dispatch });
} finally {
setShowLoader(false);
}
};
getSettingsData();
if (currentServiceProvider) {
getSettingsData();
}
// eslint-disable-next-line
}, []);
}, [currentServiceProvider]);
const toggleMsTeams = (e) => {
if (!e.target.checked && sbcDomainName) {
@@ -145,16 +107,23 @@ const SettingsForm = () => {
setErrorMessage('');
setInvalidEnableMsTeams(false);
setInvalidSbcDomainName(false);
setInvalidSipRealm(false);
setInvalidRegWebhook(false);
setInvalidUser(false);
setInvalidPassword(false);
setInvalidServiceProviderName(false);
let errorMessages = [];
let focusHasBeenSet = false;
//=============================================================================
// data checks
//=============================================================================
if (!serviceProviderName.trim()) {
errorMessages.push(
'Please enter a Service Provider Name.'
);
setInvalidServiceProviderName(true);
if (!focusHasBeenSet) {
refServiceProviderName.current.focus();
focusHasBeenSet = true;
}
}
if (enableMsTeams && !sbcDomainName) {
errorMessages.push(
'You must provide an SBC Domain Name in order to enable Microsoft Teams Direct Routing'
@@ -177,42 +146,6 @@ const SettingsForm = () => {
}
}
if (!sipRealm && (regWebhook || user || password)) {
errorMessages.push(
'You must provide a SIP Realm in order to provide a Registration Webhook'
);
setInvalidSipRealm(true);
if (!focusHasBeenSet) {
refSipRealm.current.focus();
focusHasBeenSet = true;
}
}
if (sipRealm && !regWebhook) {
errorMessages.push(
'You must provide a Registration Webhook when providing a SIP Realm'
);
setInvalidRegWebhook(true);
if (!focusHasBeenSet) {
refRegWebhook.current.focus();
focusHasBeenSet = true;
}
}
if ((user && !password) || (!user && password)) {
errorMessages.push('Username and password must be either both filled out or both empty.');
setInvalidUser(true);
setInvalidPassword(true);
if (!focusHasBeenSet) {
if (!user) {
refUser.current.focus();
} else {
refPassword.current.focus();
}
focusHasBeenSet = true;
}
}
if (errorMessages.length > 1) {
setErrorMessage(errorMessages);
return;
@@ -226,18 +159,9 @@ const SettingsForm = () => {
//=============================================================================
const data = {
ms_teams_fqdn: sbcDomainName.trim() || null,
root_domain: sipRealm.trim() || null,
name: serviceProviderName.trim(),
};
if (regWebhook) {
data.registration_hook = {
url: regWebhook.trim() || null,
method,
username: user.trim() || null,
password: password || null,
};
}
await axios({
method: 'put',
baseURL: process.env.REACT_APP_API_BASE_URL,
@@ -291,6 +215,15 @@ const SettingsForm = () => {
wideLabel
onSubmit={handleSubmit}
>
<Label htmlFor="serviceProviderName">Service Provider Name</Label>
<Input
name="serviceProviderName"
id="serviceProviderName"
value={serviceProviderName}
onChange={e => setServiceProviderName(e.target.value)}
invalid={invalidServiceProviderName}
ref={refServiceProviderName}
/>
<div>{/* needed for CSS grid layout */}</div>
<Checkbox
noLeftMargin
@@ -316,117 +249,6 @@ const SettingsForm = () => {
title={(!enableMsTeams && "You must enable Microsoft Teams Direct Routing in order to provide an SBC Domain Name") || ""}
/>
<hr />
<Label htmlFor="sipRealm">Fallback SIP Realm</Label>
<Input
name="sipRealm"
id="sipRealm"
value={sipRealm}
onChange={e => setSipRealm(e.target.value)}
placeholder="Domain name that accounts will use as a fallback"
invalid={invalidSipRealm}
autoFocus={!enableMsTeams}
ref={refSipRealm}
/>
<Label htmlFor="regWebhook">Registration Webhook</Label>
<InputGroup>
<Input
name="regWebhook"
id="regWebhook"
value={regWebhook}
onChange={e => setRegWebhook(e.target.value)}
placeholder="URL for your web application that handles registrations"
invalid={invalidRegWebhook}
ref={refRegWebhook}
disabled={!sipRealm && !regWebhook && !user && !password}
title={(
!sipRealm &&
!regWebhook &&
!user &&
!password &&
"You must provide a SIP Realm in order to enter a registration webhook"
) || ""}
/>
<Label
middle
htmlFor="method"
>
Method
</Label>
<Select
name="method"
id="method"
value={method}
onChange={e => setMethod(e.target.value)}
disabled={!sipRealm && !regWebhook && !user && !password}
title={(
!sipRealm &&
!regWebhook &&
!user &&
!password &&
"You must provide a SIP Realm in order to enter a registration webhook"
) || ""}
>
<option value="POST">POST</option>
<option value="GET">GET</option>
</Select>
</InputGroup>
{showAuth ? (
<InputGroup>
<Label indented htmlFor="user">User</Label>
<Input
name="user"
id="user"
value={user || ''}
onChange={e => setUser(e.target.value)}
placeholder="Optional"
invalid={invalidUser}
ref={refUser}
disabled={!sipRealm && !regWebhook && !user && !password}
title={(
!sipRealm &&
!regWebhook &&
!user &&
!password &&
"You must provide a SIP Realm in order to enter a registration webhook"
) || ""}
/>
<Label htmlFor="password" middle>Password</Label>
<PasswordInput
allowShowPassword
name="password"
id="password"
password={password}
setPassword={setPassword}
setErrorMessage={setErrorMessage}
placeholder="Optional"
invalid={invalidPassword}
ref={refPassword}
disabled={!sipRealm && !regWebhook && !user && !password}
title={(
!sipRealm &&
!regWebhook &&
!user &&
!password &&
"You must provide a SIP Realm in order to enter a registration webhook"
) || ""}
/>
</InputGroup>
) : (
<Button
text
formLink
type="button"
onClick={toggleAuth}
>
Use HTTP Basic Authentication
</Button>
)}
{errorMessage && (
<FormError grid message={errorMessage} />
)}

View File

@@ -1,703 +0,0 @@
import React, { useState, useEffect, useContext, useRef } from 'react';
import { useHistory } from 'react-router-dom';
import axios from 'axios';
import { NotificationDispatchContext } from '../../contexts/NotificationContext';
import Form from '../elements/Form';
import Input from '../elements/Input';
import Label from '../elements/Label';
import Checkbox from '../elements/Checkbox';
import InputGroup from '../elements/InputGroup';
import FormError from '../blocks/FormError';
import Button from '../elements/Button';
import TrashButton from '../elements/TrashButton';
import Loader from '../blocks/Loader';
import sortSipGateways from '../../helpers/sortSipGateways';
import Link from '../elements/Link';
const SipTrunkForm = props => {
const history = useHistory();
const dispatch = useContext(NotificationDispatchContext);
// Refs
const refName = useRef(null);
const refIp = useRef([]);
const refPort = useRef([]);
const refInbound = useRef([]);
const refOutbound = useRef([]);
const refTrash = useRef([]);
const refAdd = useRef(null);
// Form inputs
const [ name, setName ] = useState('');
const [ nameInvalid, setNameInvalid ] = useState(false);
const [ description, setDescription ] = useState('');
const [ e164, setE164 ] = useState(false);
const [ sipGateways, setSipGateways ] = useState([
{
sip_gateway_sid: '',
ip: '',
port: 5060,
inbound: true,
outbound: true,
invalidIp: false,
invalidPort: false,
invalidInbound: false,
invalidOutbound: false,
}
]);
const [ sipTrunks, setSipTrunks ] = useState([]);
const [ sipTrunkSid, setSipTrunkSid ] = useState('');
const [ showLoader, setShowLoader ] = useState(true);
const [ errorMessage, setErrorMessage ] = useState('');
useEffect(() => {
const getAPIData = async () => {
if (!localStorage.getItem('token')) {
history.push('/');
dispatch({
type: 'ADD',
level: 'error',
message: 'You must log in to view that page.',
});
return;
}
try {
const sipTrunksPromise = axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/VoipCarriers`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
const sipGatewaysPromise = axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/SipGateways`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
const promiseAllValues = await Promise.all([
sipTrunksPromise,
sipGatewaysPromise,
]);
const allSipTrunks = promiseAllValues[0].data;
const allSipGateways = promiseAllValues[1].data;
setSipTrunks(allSipTrunks);
if (props.type === 'setup' && allSipTrunks.length > 1) {
history.push('/internal/sip-trunks');
dispatch({
type: 'ADD',
level: 'error',
message: 'That page is only accessible during setup.',
});
}
if (props.type === 'edit' || props.type === 'setup') {
const currentSipTrunk = props.type === 'edit'
? allSipTrunks.filter(s => s.voip_carrier_sid === props.voip_carrier_sid)
: allSipTrunks;
if (props.type === 'edit' && !currentSipTrunk.length) {
history.push('/internal/sip-trunks');
dispatch({
type: 'ADD',
level: 'error',
message: 'That SIP trunk does not exist.',
});
return;
}
const currentSipGateways = allSipGateways.filter(s => {
return s.voip_carrier_sid === currentSipTrunk[0].voip_carrier_sid;
});
sortSipGateways(currentSipGateways);
if (currentSipTrunk.length) {
setName(currentSipTrunk[0].name);
setDescription(currentSipTrunk[0].description);
setE164(currentSipTrunk[0].e164_leading_plus === 1);
setSipGateways(currentSipGateways.map(s => ({
sip_gateway_sid: s.sip_gateway_sid,
ip: s.ipv4,
port: s.port,
inbound: s.inbound === 1,
outbound: s.outbound === 1,
invalidIp: false,
invalidPort: false,
invalidInbound: false,
invalidOutbound: false,
})));
setSipTrunkSid(currentSipTrunk[0].voip_carrier_sid);
}
}
setShowLoader(false);
} catch (err) {
if (err.response && err.response.status === 401) {
localStorage.removeItem('token');
sessionStorage.clear();
history.push('/');
dispatch({
type: 'ADD',
level: 'error',
message: 'Your session has expired. Please log in and try again.',
});
} else {
setErrorMessage('Something went wrong, please try again.');
dispatch({
type: 'ADD',
level: 'error',
message: (err.response && err.response.data && err.response.data.msg) || 'Unable to get accounts',
});
console.log(err.response || err);
}
setShowLoader(false);
}
};
getAPIData();
// eslint-disable-next-line
}, []);
const addSipGateway = () => {
const newSipGateways = [
...sipGateways,
{
sip_gateway_sid: '',
ip: '',
port: 5060,
inbound: true,
outbound: true,
invalidIp: false,
invalidPort: false,
invalidInbound: false,
invalidOutbound: false,
}
];
setSipGateways(newSipGateways);
};
const removeSipGateway = index => {
const newSipGateways = sipGateways.filter((s,i) => i !== index);
setSipGateways(newSipGateways);
setErrorMessage('');
};
const updateSipGateways = (e, i, key) => {
const newSipGateways = [...sipGateways];
const newValue =
key === 'invalidIp' ||
key === 'invalidPort' ||
key === 'invalidInbound' ||
key === 'invalidOutbound'
? true
: (key === 'inbound') || (key === 'outbound')
? e.target.checked
: e.target.value;
newSipGateways[i][key] = newValue;
setSipGateways(newSipGateways);
};
const resetInvalidFields = () => {
setNameInvalid(false);
const newSipGateways = [...sipGateways];
newSipGateways.forEach((s, i) => {
newSipGateways[i].invalidIp = false;
newSipGateways[i].invalidPort = false;
newSipGateways[i].invalidInbound = false;
newSipGateways[i].invalidOutbound = false;
});
setSipGateways(newSipGateways);
};
const handleSubmit = async e => {
let isMounted = true;
try {
setShowLoader(true);
e.preventDefault();
setErrorMessage('');
resetInvalidFields();
let errorMessages = [];
let focusHasBeenSet = false;
if (!name) {
errorMessages.push('Please enter a name for this SIP Trunk.');
setNameInvalid(true);
if (!focusHasBeenSet) {
refName.current.focus();
focusHasBeenSet = true;
}
}
if (!sipGateways.length) {
errorMessages.push('You must provide at least one SIP Gateway.');
if (!focusHasBeenSet) {
refAdd.current.focus();
focusHasBeenSet = true;
}
}
const regIp = /^((25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])$/;
const regFqdn = /^([a-zA-Z][^.]*)(\.[^.]+){2,}$/;
const regFqdnTopLevel = /^([a-zA-Z][^.]*)(\.[^.]+)$/;
const regPort = /^[0-9]+$/;
sipGateways.forEach(async (gateway, i) => {
//-----------------------------------------------------------------------------
// IP validation
//-----------------------------------------------------------------------------
const type = regIp.test(gateway.ip.trim())
? 'ip'
: regFqdn.test(gateway.ip.trim())
? 'fqdn'
: regFqdnTopLevel.test(gateway.ip.trim())
? 'fqdn-top-level'
: 'invalid';
if (!gateway.ip) {
errorMessages.push('The IP Address cannot be blank. Please provide an IP address or delete the row.');
updateSipGateways(null, i, 'invalidIp');
if (!focusHasBeenSet) {
refIp.current[i].focus();
focusHasBeenSet = true;
}
}
else if (type === 'fqdn-top-level') {
errorMessages.push('When using an FQDN, you must use a subdomain (e.g. sip.example.com).');
updateSipGateways(null, i, 'invalidIp');
if (!focusHasBeenSet) {
refIp.current[i].focus();
focusHasBeenSet = true;
}
}
else if (type === 'invalid') {
errorMessages.push('Please provide a valid IP address or fully qualified domain name.');
updateSipGateways(null, i, 'invalidIp');
if (!focusHasBeenSet) {
refIp.current[i].focus();
focusHasBeenSet = true;
}
}
//-----------------------------------------------------------------------------
// Port validation
//-----------------------------------------------------------------------------
if (
gateway.port && (
!(regPort.test(gateway.port.toString().trim()))
|| (parseInt(gateway.port.toString().trim()) < 0)
|| (parseInt(gateway.port.toString().trim()) > 65535)
)
) {
errorMessages.push('Please provide a valid port number between 0 and 65535');
updateSipGateways(null, i, 'invalidPort');
if (!focusHasBeenSet) {
refPort.current[i].focus();
focusHasBeenSet = true;
}
}
//-----------------------------------------------------------------------------
// inbound/outbound validation
//-----------------------------------------------------------------------------
if (type === 'fqdn' && (!gateway.outbound || gateway.inbound)) {
errorMessages.push('A fully qualified domain name may only be used for outbound calls.');
updateSipGateways(null, i, 'invalidIp');
if (gateway.inbound) updateSipGateways(null, i, 'invalidInbound');
if (!gateway.outbound) updateSipGateways(null, i, 'invalidOutbound');
if (!focusHasBeenSet) {
if (gateway.inbound) {
refInbound.current[i].focus();
} else {
refOutbound.current[i].focus();
}
focusHasBeenSet = true;
}
}
else if (!gateway.inbound && !gateway.outbound) {
errorMessages.push('Each SIP Gateway must accept inbound calls, outbound calls, or both.');
updateSipGateways(null, i, 'invalidInbound');
updateSipGateways(null, i, 'invalidOutbound');
if (!focusHasBeenSet) {
refInbound.current[i].focus();
focusHasBeenSet = true;
}
}
//-----------------------------------------------------------------------------
// duplicates validation
//-----------------------------------------------------------------------------
sipGateways.forEach((otherGateway, j) => {
if (i >= j) return;
if (!gateway.ip) return;
if (type === 'invalid') return;
if (gateway.ip === otherGateway.ip && gateway.port === otherGateway.port) {
errorMessages.push('Each SIP gateway must have a unique IP address.');
updateSipGateways(null, i, 'invalidIp');
updateSipGateways(null, i, 'invalidPort');
updateSipGateways(null, j, 'invalidIp');
updateSipGateways(null, j, 'invalidPort');
if (!focusHasBeenSet) {
refTrash.current[j].focus();
focusHasBeenSet = true;
}
}
});
});
// remove duplicate error messages
for (let i = 0; i < errorMessages.length; i++) {
for (let j = 0; j < errorMessages.length; j++) {
if (i >= j) continue;
if (errorMessages[i] === errorMessages[j]) {
errorMessages.splice(j, 1);
j = j - 1;
}
}
}
if (errorMessages.length > 1) {
setErrorMessage(errorMessages);
return;
} else if (errorMessages.length === 1) {
setErrorMessage(errorMessages[0]);
return;
}
//=============================================================================
// Submit
//=============================================================================
const creatingNewTrunk = props.type === 'add' || (props.type === 'setup' && !sipTrunks.length);
const method = creatingNewTrunk
? 'post'
: 'put';
const url = creatingNewTrunk
? '/VoipCarriers'
: `/VoipCarriers/${sipTrunkSid}`;
// Create or update SIP Trunk / VoIP Carrier
const voipCarrier = await axios({
method,
baseURL: process.env.REACT_APP_API_BASE_URL,
url,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
data: {
name: name.trim(),
description: description.trim(),
e164_leading_plus: e164 ? 1 : 0
},
});
const voip_carrier_sid = voipCarrier.data.sid;
// get updated gateway info from API in order to delete ones that user has removed from UI
let sipGatewaysFromAPI;
if (!creatingNewTrunk) {
const results = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/SipGateways',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
sipGatewaysFromAPI = results.data.filter(s => s.voip_carrier_sid === sipTrunkSid);
}
//-----------------------------------------------------------------------------
// Create or Update SIP Gateways
//-----------------------------------------------------------------------------
// Keeping track of created SIP gateways in case one throws an error, then all
// of the ones created before that (as well as the sip trunk) have to be deleted.
let completedSipGateways = [];
try {
for (const s of sipGateways) {
const creatingNewGateway = creatingNewTrunk || s.sip_gateway_sid === '';
const method = creatingNewGateway
? 'post'
: 'put';
const url = creatingNewGateway
? '/SipGateways'
: `/SipGateways/${s.sip_gateway_sid}`;
const data = {
ipv4: s.ip.trim(),
port: s.port.toString().trim(),
inbound: s.inbound,
outbound: s.outbound,
};
if (creatingNewGateway) {
data.voip_carrier_sid = voip_carrier_sid || sipTrunkSid;
}
const result = await axios({
method,
baseURL: process.env.REACT_APP_API_BASE_URL,
url,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
data,
});
if (creatingNewGateway) {
completedSipGateways.push(result.data.sid);
}
};
} catch (err) {
if (completedSipGateways.length) {
for (const sid of completedSipGateways) {
await axios({
method: 'delete',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/SipGateways/${sid}`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
}
}
if (voip_carrier_sid) {
await axios({
method: 'delete',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/VoipCarriers/${voip_carrier_sid}`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
}
throw err;
}
// delete removed gateways (after add/update in case add/update caused errors)
if (!creatingNewTrunk) {
for (const remote of sipGatewaysFromAPI) {
const match = sipGateways.filter(local => local.sip_gateway_sid === remote.sip_gateway_sid);
if (!match.length) {
await axios({
method: 'delete',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/SipGateways/${remote.sip_gateway_sid}`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
}
}
}
if (props.type === 'setup') {
isMounted = false;
history.push('/setup-complete');
} else {
isMounted = false;
history.push('/internal/sip-trunks');
const dispatchMessage = props.type === 'add'
? 'SIP trunk created successfully'
: 'SIP trunk updated successfully';
dispatch({
type: 'ADD',
level: 'success',
message: dispatchMessage
});
}
} catch(err) {
if (err.response && err.response.status === 401) {
localStorage.removeItem('token');
sessionStorage.clear();
isMounted = false;
history.push('/');
dispatch({
type: 'ADD',
level: 'error',
message: 'Your session has expired. Please log in and try again.',
});
} else {
setErrorMessage((err.response && err.response.data && err.response.data.msg) || 'Something went wrong, please try again.');
console.log(err.response || err);
}
} finally {
if (isMounted) {
setShowLoader(false);
}
}
};
return (
showLoader
? <Loader height={props.type === 'setup' ? '424px' : '376px'}/>
: <Form
large
onSubmit={handleSubmit}
>
<Label htmlFor="name">Name</Label>
<Input
large={props.type === 'setup'}
name="name"
id="name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="SIP trunk provider name"
invalid={nameInvalid}
autoFocus
ref={refName}
/>
<Label htmlFor="description">Description</Label>
<Input
large={props.type === 'setup'}
name="description"
id="description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Optional"
/>
<Label htmlFor="e164">E.164 Syntax</Label>
<Checkbox
noLeftMargin
large={props.type === 'setup'}
name="e164"
id="e164"
label="prepend a leading + on origination attempts"
checked={e164}
onChange={e => setE164(e.target.checked)}
/>
<hr style={{ margin: '0.5rem -2rem' }} />
<div
style={{ whiteSpace: 'nowrap' }}
>SIP Gateways</div>
{
sipGateways.length
? <div>{/* for CSS grid layout */}</div>
: null
}
{sipGateways.map((g, i) => (
<React.Fragment key={i}>
<Label htmlFor={`sipGatewaysIp[${i}]`}>IP Address</Label>
<InputGroup>
<Input
large={props.type === 'setup'}
name={`sipGatewaysIp[${i}]`}
id={`sipGatewaysIp[${i}]`}
value={sipGateways[i].ip}
onChange={e => updateSipGateways(e, i, 'ip')}
placeholder={'1.2.3.4'}
invalid={sipGateways[i].invalidIp}
ref={ref => refIp.current[i] = ref}
/>
<Label
middle
htmlFor={`sipGatewaysPort[${i}]`}
>
Port
</Label>
<Input
large={props.type === 'setup'}
width="5rem"
name={`sipGatewaysPort[${i}]`}
id={`sipGatewaysPort[${i}]`}
value={sipGateways[i].port}
onChange={e => updateSipGateways(e, i, 'port')}
placeholder="5060"
invalid={sipGateways[i].invalidPort}
ref={ref => refPort.current[i] = ref}
/>
<Checkbox
large={props.type === 'setup'}
id={`inbound[${i}]`}
label="Inbound"
tooltip="Sends us calls"
checked={sipGateways[i].inbound}
onChange={e => updateSipGateways(e, i, 'inbound')}
invalid={sipGateways[i].invalidInbound}
ref={ref => refInbound.current[i] = ref}
/>
<Checkbox
large={props.type === 'setup'}
id={`outbound[${i}]`}
label="Outbound"
tooltip="Accepts calls from us"
checked={sipGateways[i].outbound}
onChange={e => updateSipGateways(e, i, 'outbound')}
invalid={sipGateways[i].invalidOutbound}
ref={ref => refOutbound.current[i] = ref}
/>
<TrashButton
onClick={() => removeSipGateway(i)}
ref={ref => refTrash.current[i] = ref}
/>
</InputGroup>
</React.Fragment>
))}
<Button
square
type="button"
onClick={addSipGateway}
ref={refAdd}
>
+
</Button>
{errorMessage && (
<FormError grid message={errorMessage} />
)}
<InputGroup flexEnd spaced>
{props.type === 'edit' && (
<Button
grid
gray
type="button"
onClick={() => {
history.push('/internal/sip-trunks');
dispatch({
type: 'ADD',
level: 'info',
message: 'Changes canceled',
});
}}
>
Cancel
</Button>
)}
<Button
large={props.type === 'setup'}
grid
fullWidth={props.type === 'setup' || props.type === 'add'}
>
{props.type === 'setup'
? 'Save and Continue'
: props.type === 'add'
? 'Add SIP Trunk'
: 'Save'
}
</Button>
</InputGroup>
{props.type === 'setup' && (
<Link
formLink
right
to="/setup-complete"
>
Skip for now &mdash; I'll complete later
</Link>
)}
</Form>
);
};
export default SipTrunkForm;

View File

@@ -0,0 +1,567 @@
import React, { useState, useEffect, useContext, useRef } from 'react';
import { useParams, useHistory } from 'react-router-dom';
import axios from 'axios';
import styled from "styled-components/macro";
import { NotificationDispatchContext } from '../../contexts/NotificationContext';
import handleErrors from '../../helpers/handleErrors';
import Form from '../elements/Form';
import Input from '../elements/Input';
import Label from '../elements/Label';
import InputGroup from '../elements/InputGroup';
import PasswordInput from '../elements/PasswordInput';
import Radio from '../elements/Radio';
import Checkbox from '../elements/Checkbox';
import FileUpload from '../elements/FileUpload';
import Code from '../elements/Code';
import FormError from '../blocks/FormError';
import Button from '../elements/Button';
import Loader from '../blocks/Loader';
import { ServiceProviderValueContext } from '../../contexts/ServiceProviderContext'
const StyledButtonGroup = styled(InputGroup)`
@media (max-width: 576.98px) {
width: 100%;
& > *:first-child {
width: 100%;
flex: 1;
& > * {
width: 100%;
}
}
& > *:last-child {
width: 100%;
flex: 1;
& > * {
width: 100%;
}
}
}
${props => props.type === 'add' ? `
@media (max-width: 459.98px) {
flex-direction: column;
& > *:first-child {
width: 100%;
margin: 0 0 1rem 0;
}
}
` : ''}
`;
const SpeechServicesAddEdit = (props) => {
let history = useHistory();
const dispatch = useContext(NotificationDispatchContext);
const currentServiceProvider = useContext(ServiceProviderValueContext);
const jwt = localStorage.getItem('token');
let { speech_service_sid } = useParams();
const type = speech_service_sid ? 'edit' : 'add';
// Refs
const refVendorGoogle = useRef(null);
const refVendorAws = useRef(null);
const refAccessKeyId = useRef(null);
const refSecretAccessKey = useRef(null);
const refUseForTts = useRef(null);
const refUseForStt = useRef(null);
// Form inputs
const [ vendor, setVendor ] = useState('');
const [ serviceKey, setServiceKey ] = useState('');
const [ displayedServiceKey, setDisplayedServiceKey ] = useState('');
const [ accessKeyId, setAccessKeyId ] = useState('');
const [ secretAccessKey, setSecretAccessKey ] = useState('');
const [ useForTts, setUseForTts ] = useState(false);
const [ useForStt, setUseForStt ] = useState(false);
// Invalid form inputs
const [ invalidVendorGoogle, setInvalidVendorGoogle ] = useState(false);
const [ invalidVendorAws, setInvalidVendorAws ] = useState(false);
const [ invalidAccessKeyId, setInvalidAccessKeyId ] = useState(false);
const [ invalidSecretAccessKey, setInvalidSecretAccessKey ] = useState(false);
const [ invalidUseForTts, setInvalidUseForTts ] = useState(false);
const [ invalidUseForStt, setInvalidUseForStt ] = useState(false);
const [ originalTtsValue, setOriginalTtsValue ] = useState(null);
const [ originalSttValue, setOriginalSttValue ] = useState(null);
const [ validServiceKey, setValidServiceKey ] = useState(false);
const [ showLoader, setShowLoader ] = useState(true);
const [ errorMessage, setErrorMessage ] = useState('');
useEffect(() => {
const getAPIData = async () => {
let isMounted = true;
try {
if (type === 'edit') {
const speechCredential = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/ServiceProviders/${currentServiceProvider}/SpeechCredentials/${speech_service_sid}`,
headers: {
Authorization: `Bearer ${jwt}`,
},
});
let serviceKeyJson = '';
let displayedServiceKeyJson = '';
try {
serviceKeyJson = JSON.parse(speechCredential.data.service_key);
displayedServiceKeyJson = JSON.stringify(serviceKeyJson, null, 2);
} catch (err) {
}
setVendor( speechCredential.data.vendor || undefined);
setServiceKey( serviceKeyJson || '');
setDisplayedServiceKey( displayedServiceKeyJson || '');
setAccessKeyId( speechCredential.data.access_key_id || '');
setSecretAccessKey( speechCredential.data.secret_access_key || '');
setUseForTts( speechCredential.data.use_for_tts || false);
setUseForStt( speechCredential.data.use_for_stt || false);
setOriginalTtsValue( speechCredential.data.use_for_tts || false);
setOriginalSttValue( speechCredential.data.use_for_stt || false);
}
setShowLoader(false);
} catch (err) {
isMounted = false;
handleErrors({
err,
history,
dispatch,
redirect: '/internal/speech-services',
fallbackMessage: 'That speech service does not exist',
preferFallback: true,
});
} finally {
if (isMounted) {
setShowLoader(false);
}
}
};
getAPIData();
// eslint-disable-next-line
}, []);
const handleFileUpload = async (e) => {
setErrorMessage('');
setServiceKey('');
setDisplayedServiceKey('');
const file = e.target.files[0];
if (!file) {
setValidServiceKey(false);
return;
}
const fileAsText = await file.text();
try {
const fileJson = JSON.parse(fileAsText);
if (!fileJson.client_email || !fileJson.private_key) {
setValidServiceKey(false);
setErrorMessage('Invalid service key file, missing data.');
return;
}
setValidServiceKey(true);
setServiceKey(fileJson);
setDisplayedServiceKey(JSON.stringify(fileJson, null, 2));
} catch (err) {
setValidServiceKey(false);
setErrorMessage('Invalid service key file, could not parse as JSON.');
}
};
const handleSubmit = async (e) => {
let isMounted = true;
try {
setShowLoader(true);
e.preventDefault();
setErrorMessage('');
setInvalidVendorGoogle(false);
setInvalidVendorAws(false);
setInvalidAccessKeyId(false);
setInvalidSecretAccessKey(false);
setInvalidUseForTts(false);
setInvalidUseForStt(false);
let errorMessages = [];
let focusHasBeenSet = false;
if (!vendor) {
errorMessages.push('Please select a vendor.');
setInvalidVendorGoogle(true);
setInvalidVendorAws(true);
if (!focusHasBeenSet) {
refVendorGoogle.current.focus();
focusHasBeenSet = true;
}
}
if (vendor === 'google' && !serviceKey) {
errorMessages.push('Please upload a service key file.');
}
if (vendor === 'aws' && !accessKeyId) {
errorMessages.push('Please provide an access key ID.');
setInvalidAccessKeyId(true);
if (!focusHasBeenSet) {
refAccessKeyId.current.focus();
focusHasBeenSet = true;
}
}
if (vendor === 'aws' && !secretAccessKey) {
errorMessages.push('Please provide a secret access key.');
setInvalidSecretAccessKey(true);
if (!focusHasBeenSet) {
refSecretAccessKey.current.focus();
focusHasBeenSet = true;
}
}
if (errorMessages.length > 1) {
setErrorMessage(errorMessages);
return;
} else if (errorMessages.length === 1) {
setErrorMessage(errorMessages[0]);
return;
}
// Check if user already has a speech service with the selected vendor
const speechServices = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/ServiceProviders/${currentServiceProvider}/SpeechCredentials`,
headers: {
Authorization: `Bearer ${jwt}`,
},
});
if (type === 'add' && speechServices.data.some(speech => speech.vendor === vendor)) {
setErrorMessage('You can only have one speech credential per vendor.');
setShowLoader(false);
if (vendor === 'google') {
setInvalidVendorGoogle(true);
if (!focusHasBeenSet) {
refVendorGoogle.current.focus();
}
} else if (vendor === 'aws') {
setInvalidVendorAws(true);
if (!focusHasBeenSet) {
refVendorAws.current.focus();
}
}
return;
}
//===============================================
// Submit
//===============================================
const method = type === 'add'
? 'post'
: 'put';
const url = type === 'add'
? `/ServiceProviders/${currentServiceProvider}/SpeechCredentials`
: `/ServiceProviders/${currentServiceProvider}/SpeechCredentials/${speech_service_sid}`;
const postResults = await axios({
method,
baseURL: process.env.REACT_APP_API_BASE_URL,
url,
headers: {
Authorization: `Bearer ${jwt}`,
},
data: {
vendor,
service_key: vendor === 'google' ? JSON.stringify(serviceKey) : null,
access_key_id: vendor === 'aws' ? accessKeyId : null,
secret_access_key: vendor === 'aws' ? secretAccessKey : null,
use_for_tts: useForTts,
use_for_stt: useForStt,
}
});
if (type === 'add') {
if (!postResults.data || !postResults.data.sid) {
throw new Error('Error retrieving response data');
}
speech_service_sid = postResults.data.sid;
}
//===============================================
// Test speech credentials
//===============================================
if (useForTts || useForStt) {
const testResults = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/ServiceProviders/${currentServiceProvider}/SpeechCredentials/${speech_service_sid}/test`,
headers: {
Authorization: `Bearer ${jwt}`,
},
});
if (useForTts && testResults.data.tts.status === 'not tested') {
errorMessages.push('text-to-speech was not tested, please try again.');
}
if (useForStt && testResults.data.stt.status === 'not tested') {
errorMessages.push('speech-to-text was not tested, please try again.');
}
const ttsReason = (useForTts && testResults.data.tts.status === 'fail')
? testResults.data.tts.reason
: null;
const sttReason = (useForStt && testResults.data.stt.status === 'fail')
? testResults.data.stt.reason
: null;
if (ttsReason && (ttsReason === sttReason)) {
errorMessages.push(ttsReason);
} else {
if (ttsReason) {
errorMessages.push(`Text-to-speech error: ${ttsReason}`);
}
if (sttReason) {
errorMessages.push(`Speech-to-text error: ${sttReason}`);
}
}
if (errorMessages.length > 1) {
setErrorMessage(errorMessages);
} else if (errorMessages.length === 1) {
setErrorMessage(errorMessages[0]);
}
if (errorMessages.length) {
if (type === 'add') {
await axios({
method: 'delete',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/ServiceProviders/${currentServiceProvider}/SpeechCredentials/${speech_service_sid}`,
headers: {
Authorization: `Bearer ${jwt}`,
},
});
}
if (type === 'edit') {
await axios({
method,
baseURL: process.env.REACT_APP_API_BASE_URL,
url,
headers: {
Authorization: `Bearer ${jwt}`,
},
data: {
use_for_tts: originalTtsValue,
use_for_stt: originalSttValue,
}
});
}
return;
}
}
//===============================================
// If successful, go to speech services
//===============================================
isMounted = false;
history.push('/internal/speech-services');
const dispatchMessage = type === 'add'
? 'Speech service created successfully'
: 'Speech service updated successfully';
dispatch({
type: 'ADD',
level: 'success',
message: dispatchMessage
});
} catch (err) {
if (err.response && err.response.status === 401) {
localStorage.clear();
sessionStorage.clear();
isMounted = false;
history.push('/');
dispatch({
type: 'ADD',
level: 'error',
message: 'Your session has expired. Please log in and try again.',
});
} else {
setErrorMessage(
(err.response && err.response.data && err.response.data.msg) ||
err.message || 'Something went wrong, please try again.'
);
console.error(err.response || err);
}
} finally {
if (isMounted) {
setShowLoader(false);
}
}
};
return (
showLoader ? (
<Loader height={props.type === 'add' ? '424px' : '376px'}/>
) : (
<Form
large
onSubmit={handleSubmit}
>
<Label htmlFor="name">Vendor</Label>
<InputGroup>
<Radio
noLeftMargin
name="vendor"
id="google"
label="Google"
checked={vendor === 'google'}
onChange={() => setVendor('google')}
invalid={invalidVendorGoogle}
ref={refVendorGoogle}
disabled={type === 'edit'}
/>
<Radio
name="vendor"
id="aws"
label="Amazon Web Services"
checked={vendor === 'aws'}
onChange={() => setVendor('aws')}
invalid={invalidVendorAws}
ref={refVendorAws}
disabled={type === 'edit'}
/>
</InputGroup>
{vendor === 'google' ? (
<>
<Label htmlFor="serviceKey">Service Key</Label>
{type === 'add' && (
<FileUpload
id="serviceKey"
onChange={handleFileUpload}
validFile={validServiceKey}
/>
)}
{displayedServiceKey && (
<>
{type === 'add' && (
<span></span>
)}
<Code>{displayedServiceKey}</Code>
</>
)}
</>
) : vendor === 'aws' ? (
<>
<Label htmlFor="accessKeyId">Access Key ID</Label>
<Input
name="accessKeyId"
id="accessKeyId"
value={accessKeyId}
onChange={e => setAccessKeyId(e.target.value)}
placeholder=""
invalid={invalidAccessKeyId}
ref={refAccessKeyId}
disabled={type === 'edit'}
/>
<Label htmlFor="secretAccessKey">Secret Access Key</Label>
<PasswordInput
allowShowPassword
name="secretAccessKey"
id="secretAccessKey"
password={secretAccessKey}
setPassword={setSecretAccessKey}
setErrorMessage={setErrorMessage}
invalid={invalidSecretAccessKey}
ref={refSecretAccessKey}
disabled={type === 'edit'}
/>
</>
) : (
null
)}
{vendor === 'google' || vendor === 'aws' ? (
<>
<div/>
<Checkbox
noLeftMargin
name="useForTts"
id="useForTts"
label="Use for text-to-speech"
checked={useForTts}
onChange={e => setUseForTts(e.target.checked)}
invalid={invalidUseForTts}
ref={refUseForTts}
/>
<div/>
<Checkbox
noLeftMargin
name="useForStt"
id="useForStt"
label="Use for speech-to-text"
checked={useForStt}
onChange={e => setUseForStt(e.target.checked)}
invalid={invalidUseForStt}
ref={refUseForStt}
/>
</>
) : (
null
)}
{errorMessage && (
<FormError grid message={errorMessage} />
)}
<StyledButtonGroup flexEnd spaced type={type}>
<Button
rounded="true"
gray
type="button"
onClick={() => {
history.push('/internal/speech-services');
dispatch({
type: 'ADD',
level: 'info',
message: type === 'add' ? 'New speech service canceled' :'Changes canceled',
});
}}
>
Cancel
</Button>
<Button rounded="true">
{type === 'add'
? 'Add Speech Service'
: 'Save'
}
</Button>
</StyledButtonGroup>
</Form>
)
);
};
export default SpeechServicesAddEdit;

View File

@@ -69,6 +69,7 @@ const Login = props => {
// They're saved to sessionStorage so that the data does not persist.
sessionStorage.setItem('user_sid', response.data.user_sid);
sessionStorage.setItem('old_password', password);
localStorage.setItem('token', response.data.token);
history.push('/create-password');
return;
}
@@ -143,20 +144,20 @@ const Login = props => {
return;
}
const { sip_realm, registration_hook } = accounts[0];
// const { sip_realm, registration_hook } = accounts[0];
if (
(!sip_realm || !registration_hook) &&
!applications.length
) {
history.push('/configure-account');
return;
}
// if (
// (!sip_realm || !registration_hook) &&
// !applications.length
// ) {
// history.push('/configure-account');
// return;
// }
if (!applications.length) {
history.push('/create-application');
return;
}
// if (!applications.length) {
// history.push('/create-application');
// return;
// }
history.push('/internal/accounts');

View File

@@ -4,9 +4,11 @@ import axios from 'axios';
import { NotificationDispatchContext } from '../../../contexts/NotificationContext';
import InternalTemplate from '../../templates/InternalTemplate';
import TableContent from '../../blocks/TableContent.js';
import { ServiceProviderValueContext } from '../../../contexts/ServiceProviderContext';
const AccountsList = () => {
let history = useHistory();
const currentServiceProvider = useContext(ServiceProviderValueContext);
const dispatch = useContext(NotificationDispatchContext);
useEffect(() => {
document.title = `Accounts | Jambonz | Open Source CPAAS`;
@@ -26,10 +28,11 @@ const AccountsList = () => {
});
return;
}
if(!currentServiceProvider) return [];
const results = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/Accounts',
url: `/ServiceProviders/${currentServiceProvider}/Accounts`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},

View File

@@ -0,0 +1,257 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable react/react-in-jsx-scope */
import React, { useContext, useState, useEffect } from "react";
import { useHistory } from "react-router-dom";
import axios from "axios";
import moment from "moment";
import styled from "styled-components/macro";
import { NotificationDispatchContext } from "../../../contexts/NotificationContext";
import InternalTemplate from "../../templates/InternalTemplate";
import Button from "../../../components/elements/Button";
import InputGroup from "../../../components/elements/InputGroup";
import Label from "../../../components/elements/Label";
import Select from "../../../components/elements/Select";
import AntdTable from "../../../components/blocks/AntdTable";
import handleErrors from "../../../helpers/handleErrors";
import { ServiceProviderValueContext } from '../../../contexts/ServiceProviderContext';
const StyledButton = styled(Button)`
& > span {
height: 2rem;
}
`;
const StyledInputGroup = styled(InputGroup)`
padding: 1rem 1rem 0;
`;
const AccountSelect = styled(Select)`
min-width: 150px;
`;
const AlertsIndex = () => {
let history = useHistory();
const dispatch = useContext(NotificationDispatchContext);
const jwt = localStorage.getItem("token");
const currentServiceProvider = useContext(ServiceProviderValueContext);
// Table props
const [alertsData, setAlertsData] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [rowCount, setRowCount] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
// Filter values
const [account, setAccount] = useState("");
const [accountList, setAccountList] = useState([]);
const [attemptedAt, setAttemptedAt] = useState("today");
//=============================================================================
// Define Table props
//=============================================================================
const Columns = [
{
title: "Date",
dataIndex: "time",
key: "time",
width: 250,
},
{
title: "Message",
dataIndex: "message",
key: "message",
width: 250,
},
];
const { height } = window.screen;
const renderPagination = (page, type, originElement) => {
let node = originElement;
switch (type) {
case "page":
node = <StyledButton gray={currentPage !== page}>{page}</StyledButton>;
break;
case "prev":
node = <StyledButton>{`<`}</StyledButton>;
break;
case "next":
node = <StyledButton>{`>`}</StyledButton>;
break;
default:
}
return node;
};
//=============================================================================
// Get alerts
//=============================================================================
const getAlerts = async () => {
let isMounted = true;
try {
let filter = {
page: currentPage,
count: rowCount,
};
if (!account) {
setAlertsData([]);
setTotalCount(0);
return;
}
setLoading(true);
switch (attemptedAt) {
case "today":
filter.start = moment().startOf("date").toISOString();
break;
case "7d":
filter.days = 7;
break;
default:
}
const alerts = await axios({
method: "get",
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/Accounts/${account}/Alerts`,
headers: {
Authorization: `Bearer ${jwt}`,
},
params: {
...filter,
},
});
if (isMounted) {
const { total, data } = alerts.data;
const simplififedAlerts = data.map((alert, index) => ({
...alert,
id: index,
time: alert.time
? moment(alert.time).format("YYYY MM.DD hh:mm a")
: "",
message: alert.message,
}));
setAlertsData(simplififedAlerts);
setTotalCount(total);
}
} catch (err) {
handleErrors({ err, history, dispatch });
} finally {
if (isMounted) {
setLoading(false);
}
}
};
useEffect(() => {
if (currentPage === 1) {
getAlerts();
} else {
setCurrentPage(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account, attemptedAt]);
useEffect(() => {
getAlerts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, rowCount]);
useEffect(() => {
if (currentServiceProvider) {
const getAccounts = async () => {
let isMounted = true;
try {
setLoading(true);
const accountResponse = await axios({
method: "get",
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/ServiceProviders/${currentServiceProvider}/Accounts`,
headers: {
Authorization: `Bearer ${jwt}`,
},
});
setAccountList((accountResponse.data || []).sort((a, b) => a.name.localeCompare(b.name)));
if (accountResponse.data.length > 0) {
setAccount(accountResponse.data[0].account_sid);
} else {
setAccount("");
}
} catch (err) {
handleErrors({ err, history, dispatch });
} finally {
if (isMounted) {
setLoading(false);
}
}
};
getAccounts();
} else {
setAccountList([]);
}
}, [currentServiceProvider]);
return (
<InternalTemplate title="Alerts">
<StyledInputGroup flexEnd space>
<Label indented htmlFor="account">
Account
</Label>
<AccountSelect
name="account"
id="account"
value={account}
onChange={(e) => setAccount(e.target.value)}
>
{accountList.map((acc) => (
<option key={acc.account_sid} value={acc.account_sid}>{acc.name}</option>
))}
</AccountSelect>
<Label middle htmlFor="daterange">
Date
</Label>
<Select
name="daterange"
id="daterange"
value={attemptedAt}
onChange={(e) => setAttemptedAt(e.target.value)}
>
<option value="today">today</option>
<option value="7d">last 7d</option>
</Select>
</StyledInputGroup>
<AntdTable
dataSource={alertsData}
columns={Columns}
rowKey="id"
loading={loading}
pagination={{
position: ["bottomCenter"],
onChange: (page, size) => {
setCurrentPage(page);
setRowCount(size);
},
showTotal: (total) => `Total: ${total} records`,
current: currentPage,
total: totalCount,
pageSize: rowCount,
pageSizeOptions: [25, 50, 100],
showSizeChanger: true,
itemRender: renderPagination,
showLessItems: true,
}}
scroll={{ y: Math.max(height - 660, 200) }}
/>
</InternalTemplate>
);
};
export default AlertsIndex;

View File

@@ -1,21 +1,90 @@
import React, { useEffect, useContext } from 'react';
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useContext, useState, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import axios from 'axios';
import { NotificationDispatchContext } from '../../../contexts/NotificationContext';
import InternalTemplate from '../../templates/InternalTemplate';
import TableContent from '../../blocks/TableContent.js';
import styled from "styled-components/macro";
import Select from "../../../components/elements/Select";
import InputGroup from "../../../components/elements/InputGroup";
import { ServiceProviderValueContext } from '../../../contexts/ServiceProviderContext';
import handleErrors from "../../../helpers/handleErrors";
const FilterLabel = styled.span`
color: #231f20;
text-align: right;
font-size: 14px;
margin-left: 1rem;
margin-right: 0.5rem;
`;
const StyledInputGroup = styled(InputGroup)`
padding: 1rem 1rem 0;
@media (max-width: 767.98px) {
display: grid;
grid-template-columns: auto 1fr auto 1fr;
grid-row-gap: 1rem;
}
@media (max-width: 575.98px) {
display: grid;
grid-template-columns: auto 1fr;
grid-row-gap: 1rem;
}
`;
const AccountSelect = styled(Select)`
min-width: 150px;
`;
const ApplicationsList = () => {
let history = useHistory();
const dispatch = useContext(NotificationDispatchContext);
const currentServiceProvider = useContext(ServiceProviderValueContext);
const jwt = localStorage.getItem('token');
const [account, setAccount] = useState("");
const [accountList, setAccountList] = useState([]);
useEffect(() => {
document.title = `Applications | Jambonz | Open Source CPAAS`;
});
useEffect(() => {
if (currentServiceProvider) {
const getAccounts = async () => {
try {
const accountResponse = await axios({
method: "get",
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/ServiceProviders/${currentServiceProvider}/Accounts`,
headers: {
Authorization: `Bearer ${jwt}`,
},
});
setAccountList((accountResponse.data || []).sort((a, b) => a.name.localeCompare(b.name)));
if (accountResponse.data.length > 0) {
setAccount(accountResponse.data[0].account_sid);
} else {
setAccount("");
}
} catch (err) {
handleErrors({ err, history, dispatch });
}
};
getAccounts();
} else {
setAccountList([]);
}
}, [currentServiceProvider]);
//=============================================================================
// Get applications
//=============================================================================
const getApplications = async () => {
const getApplications = useCallback(async () => {
try {
if (!localStorage.getItem('token')) {
history.push('/');
@@ -26,18 +95,13 @@ const ApplicationsList = () => {
});
return;
}
if (!account) {
return [];
}
const applicationsPromise = axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/Applications',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
const accountsPromise = axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/Accounts',
url: `/Accounts/${account}/Applications`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
@@ -45,21 +109,19 @@ const ApplicationsList = () => {
const promiseAllValues = await Promise.all([
applicationsPromise,
accountsPromise,
]);
const applications = promiseAllValues[0].data;
const accounts = promiseAllValues[1].data;
const simplifiedApplications = applications.map(app => {
const account = accounts.filter(acc => acc.account_sid === app.account_sid);
return {
sid: app.application_sid,
name: app.name,
account_sid: app.account_sid,
call_hook_url: app.call_hook && app.call_hook.url,
status_hook_url: app.call_status_hook && app.call_status_hook.url,
account: account[0].name,
sid: app.application_sid,
name: app.name,
account_sid: app.account_sid,
call_hook_url: app.call_hook && app.call_hook.url,
status_hook_url: app.call_status_hook && app.call_status_hook.url,
messaging_hook_url: app.messaging_hook && app.messaging_hook.url,
account: app.account
};
});
return(simplifiedApplications);
@@ -82,17 +144,18 @@ const ApplicationsList = () => {
console.log(err.response || err);
}
}
};
}, [account]);
//=============================================================================
// Delete application
//=============================================================================
const formatApplicationToDelete = app => {
return [
{ name: 'Name:', content: app.name || '[none]' },
{ name: 'Account:', content: app.account || '[none]' },
{ name: 'Calling Webhook:', content: app.call_hook_url || '[none]' },
{ name: 'Call Status Webhook:', content: app.status_hook_url || '[none]' },
{ name: 'Name:', content: app.name || '[none]' },
{ name: 'Account:', content: app.account || '[none]' },
{ name: 'Calling Webhook:', content: app.call_hook_url || '[none]' },
{ name: 'Call Status Webhook:', content: app.status_hook_url || '[none]' },
{ name: 'Messaging Webhook:', content: app.messaging_hook_url || '[none]' },
];
};
const deleteApplication = async applicationToDelete => {
@@ -196,6 +259,19 @@ const ApplicationsList = () => {
addButtonText="Add an Application"
addButtonLink="/internal/applications/add"
>
<StyledInputGroup flexEnd space>
<FilterLabel htmlFor="account">Account:</FilterLabel>
<AccountSelect
name="account"
id="account"
value={account}
onChange={(e) => setAccount(e.target.value)}
>
{accountList.map((acc) => (
<option key={acc.account_sid} value={acc.account_sid}>{acc.name}</option>
))}
</AccountSelect>
</StyledInputGroup>
<TableContent
name="application"
urlParam="applications"

View File

@@ -1,26 +1,27 @@
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import InternalTemplate from '../../templates/InternalTemplate';
import SipTrunkForm from '../../forms/SipTrunkForm';
import CarrierForm from '../../forms/CarrierForm';
import Sbcs from '../../blocks/Sbcs';
const SipTrunksAddEdit = () => {
const CarriersAddEdit = () => {
let { voip_carrier_sid } = useParams();
const pageTitle = voip_carrier_sid ? 'Edit SIP Trunk' : 'Add SIP Trunk';
const pageTitle = voip_carrier_sid ? 'Edit Carrier' : 'Add Carrier';
useEffect(() => {
document.title = `${pageTitle} | Jambonz | Open Source CPAAS`;
});
return (
<InternalTemplate
type="form"
title={pageTitle}
subtitle={<Sbcs />}
breadcrumbs={[
{ name: 'SIP Trunks', url: '/internal/sip-trunks' },
{ name: 'Carriers', url: '/internal/carriers' },
{ name: pageTitle },
]}
>
<SipTrunkForm
<CarrierForm
type={voip_carrier_sid ? 'edit' : 'add'}
voip_carrier_sid={voip_carrier_sid}
/>
@@ -28,4 +29,4 @@ const SipTrunksAddEdit = () => {
);
};
export default SipTrunksAddEdit;
export default CarriersAddEdit;

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useContext } from 'react';
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useContext, useCallback } from 'react';
import axios from 'axios';
import { useHistory } from 'react-router-dom';
import { NotificationDispatchContext } from '../../../contexts/NotificationContext';
@@ -6,18 +7,21 @@ import InternalTemplate from '../../templates/InternalTemplate';
import TableContent from '../../blocks/TableContent.js';
import Sbcs from '../../blocks/Sbcs';
import sortSipGateways from '../../../helpers/sortSipGateways';
import { ServiceProviderValueContext } from '../../../contexts/ServiceProviderContext';
const SipTrunksList = () => {
const CarriersList = () => {
let history = useHistory();
const dispatch = useContext(NotificationDispatchContext);
const currentServiceProvider = useContext(ServiceProviderValueContext);
useEffect(() => {
document.title = `SIP Trunks | Jambonz | Open Source CPAAS`;
});
document.title = `Carriers | Jambonz | Open Source CPAAS`;
}, []);
//=============================================================================
// Get sip trunks
//=============================================================================
const getSipTrunks = async () => {
const getCarriers = useCallback(async () => {
try {
if (!localStorage.getItem('token')) {
history.push('/');
@@ -28,30 +32,33 @@ const SipTrunksList = () => {
});
return;
}
if(!currentServiceProvider) return [];
// Get all SIP trunks
const trunkResults = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/VoipCarriers',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
// Get all SIP gateways
const gatewayResults = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/SipGateways',
url: `/ServiceProviders/${currentServiceProvider}/VoipCarriers`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
// Add appropriate gateways to each trunk
const trunkMap = {};
for (const t of trunkResults.data) {
const gws = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/SipGateways?voip_carrier_sid=${t.voip_carrier_sid}`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
trunkMap[t.voip_carrier_sid] = gws.data;
}
const trunksWithGateways = trunkResults.data.map(t => {
const gateways = gatewayResults.data.filter(g => t.voip_carrier_sid === g.voip_carrier_sid);
const gateways = trunkMap[t.voip_carrier_sid] || [];
sortSipGateways(gateways);
return {
...t,
@@ -59,16 +66,19 @@ const SipTrunksList = () => {
};
});
const simplifiedSipTrunks = trunksWithGateways.map(t => ({
const simplifiedCarriers = trunksWithGateways.map(t => ({
sid: t.voip_carrier_sid,
name: t.name,
description: t.description,
gatewaysConcat: t.gateways.map(g => `${g.ipv4}:${g.port}`).join(', '),
status: t.is_active === 1 ? "active" : "inactive",
gatewaysConcat: `${
t.gateways.filter((item) => item.inbound === 1).length
} inbound, ${
t.gateways.filter((item) => item.outbound === 1).length
} outbound`,
gatewaysList: t.gateways.map(g => `${g.ipv4}:${g.port}`),
gatewaysSid: t.gateways.map(g => g.sip_gateway_sid),
}));
return(simplifiedSipTrunks);
return(simplifiedCarriers);
} catch (err) {
if (err.response && err.response.status === 401) {
localStorage.removeItem('token');
@@ -88,25 +98,23 @@ const SipTrunksList = () => {
console.log(err.response || err);
}
}
};
}, [currentServiceProvider, history, dispatch]);
//=============================================================================
// Delete sip trunk
//=============================================================================
const formatSipTrunkToDelete = trunk => {
const formatCarrierToDelete = trunk => {
const gatewayName = trunk.gatewaysList.length > 1
? 'SIP Gateways:'
: 'SIP Gateway:';
const gatewayContent = trunk.gatewaysList.length > 1
? trunk.gatewaysList
: trunk.gatewaysList[0];
return [
return [
{ name: 'Name:', content: trunk.name || '[none]' },
{ name: 'Description:', content: trunk.description || '[none]' },
{ name: gatewayName, content: gatewayContent || '[none]' },
{ name: 'Status:', content: trunk.status || '[none]' },
{ name: gatewayName, content: trunk.gatewaysConcat || '[none]' },
];
};
const deleteSipTrunk = async sipTrunkToDelete => {
const deleteCarrier = async carrierToDelete => {
try {
if (!localStorage.getItem('token')) {
history.push('/');
@@ -118,7 +126,7 @@ const SipTrunksList = () => {
return;
}
// delete associated gateways
for (const sid of sipTrunkToDelete.gatewaysSid) {
for (const sid of carrierToDelete.gatewaysSid) {
await axios({
method: 'delete',
baseURL: process.env.REACT_APP_API_BASE_URL,
@@ -132,7 +140,7 @@ const SipTrunksList = () => {
await axios({
method: 'delete',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/VoipCarriers/${sipTrunkToDelete.sid}`,
url: `/VoipCarriers/${carrierToDelete.sid}`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
@@ -160,25 +168,25 @@ const SipTrunksList = () => {
//=============================================================================
return (
<InternalTemplate
title="SIP Trunks"
addButtonText="Add a SIP Trunk"
addButtonLink="/internal/sip-trunks/add"
title="Carriers"
addButtonText="Add a Carrier"
addButtonLink="/internal/carriers/add"
subtitle={<Sbcs />}
>
<TableContent
name="SIP trunk"
urlParam="sip-trunks"
getContent={getSipTrunks}
name="Carrier"
urlParam="carriers"
getContent={getCarriers}
columns={[
{ header: 'Name', key: 'name' },
{ header: 'Description', key: 'description' },
{ header: 'SIP Gateways', key: 'gatewaysConcat' },
{ header: 'Status', key: 'status' },
{ header: 'Gateways', key: 'gatewaysConcat' },
]}
formatContentToDelete={formatSipTrunkToDelete}
deleteContent={deleteSipTrunk}
formatContentToDelete={formatCarrierToDelete}
deleteContent={deleteCarrier}
/>
</InternalTemplate>
);
};
export default SipTrunksList;
export default CarriersList;

View File

@@ -1,22 +1,25 @@
import React, { useState, useEffect, useContext } from 'react';
import React, { useState, useEffect, useContext, useCallback } from 'react';
import axios from 'axios';
import { useHistory } from 'react-router-dom';
import { NotificationDispatchContext } from '../../../contexts/NotificationContext';
import InternalTemplate from '../../templates/InternalTemplate';
import TableContent from '../../blocks/TableContent.js';
import phoneNumberFormat from '../../../helpers/phoneNumberFormat';
import { ServiceProviderValueContext } from '../../../contexts/ServiceProviderContext';
const PhoneNumbersList = () => {
let history = useHistory();
const dispatch = useContext(NotificationDispatchContext);
const currentServiceProvider = useContext(ServiceProviderValueContext);
useEffect(() => {
document.title = `Phone Number Routing | Jambonz | Open Source CPAAS`;
});
}, []);
//=============================================================================
// Get phone numbers
//=============================================================================
const getPhoneNumbers = async () => {
const getPhoneNumbers = useCallback(async () => {
try {
if (!localStorage.getItem('token')) {
history.push('/');
@@ -27,10 +30,11 @@ const PhoneNumbersList = () => {
});
return;
}
if(!currentServiceProvider) return [];
const phoneNumbersPromise = axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/PhoneNumbers',
url: `/ServiceProviders/${currentServiceProvider}/PhoneNumbers`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
@@ -38,7 +42,7 @@ const PhoneNumbersList = () => {
const accountsPromise = axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/Accounts',
url: `/ServiceProviders/${currentServiceProvider}/Accounts`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
@@ -46,7 +50,7 @@ const PhoneNumbersList = () => {
const applicationsPromise = axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/Applications',
url: `/ServiceProviders/${currentServiceProvider}/Applications`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
@@ -54,7 +58,7 @@ const PhoneNumbersList = () => {
const sipTrunksPromise = axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: '/VoipCarriers',
url: `/ServiceProviders/${currentServiceProvider}/VoipCarriers`,
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
@@ -120,7 +124,7 @@ const PhoneNumbersList = () => {
console.log(err.response || err);
}
}
};
}, [currentServiceProvider, dispatch, history]);
//=============================================================================
// Delete phone number

View File

@@ -0,0 +1,423 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useContext, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import axios from "axios";
import moment from "moment";
import styled from "styled-components/macro";
import { NotificationDispatchContext } from "../../../contexts/NotificationContext";
import InternalTemplate from "../../templates/InternalTemplate";
import AntdTable from "../../../components/blocks/AntdTable";
import phoneNumberFormat from "../../../helpers/phoneNumberFormat";
import timeFormat from "../../../helpers/timeFormat";
import Label from "../../../components/elements/Label";
import Button from "../../../components/elements/Button";
import InputGroup from "../../../components/elements/InputGroup";
import Select from "../../../components/elements/Select";
import handleErrors from "../../../helpers/handleErrors";
import { ServiceProviderValueContext } from '../../../contexts/ServiceProviderContext';
const FilterLabel = styled.span`
color: #231f20;
text-align: right;
font-size: 14px;
margin-left: 1rem;
margin-right: 0.5rem;
`;
const ExpandedSection = styled.div`
display: grid;
grid-template-columns: auto 1fr;
grid-gap: 0.5rem;
width: 100%;
padding: 1rem;
`;
const StyledButton = styled(Button)`
& > span {
height: 2rem;
}
`;
const StyledInputGroup = styled(InputGroup)`
padding: 1rem 1rem 0;
@media (max-width: 767.98px) {
display: grid;
grid-template-columns: auto 1fr auto 1fr;
grid-row-gap: 1rem;
}
@media (max-width: 575.98px) {
display: grid;
grid-template-columns: auto 1fr;
grid-row-gap: 1rem;
}
`;
const AccountSelect = styled(Select)`
min-width: 150px;
`;
const RecentCallsIndex = () => {
let history = useHistory();
const dispatch = useContext(NotificationDispatchContext);
const jwt = localStorage.getItem("token");
const currentServiceProvider = useContext(ServiceProviderValueContext);
// Table props
const [recentCallsData, setRecentCallsData] = useState([]);
const [rawData, setRawData] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [rowCount, setRowCount] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [expandedRowKeys, setExpandedRowKeys] = useState([]);
// Filter values
const [account, setAccount] = useState("");
const [accountList, setAccountList] = useState([]);
const [attemptedAt, setAttemptedAt] = useState("today");
const [dirFilter, setDirFilter] = useState("io");
const [answered, setAnswered] = useState("all");
// width
const { height } = window.screen;
//=============================================================================
// Define Table props
//=============================================================================
const Columns = [
{
title: "Date",
dataIndex: "attempted_at",
key: "attempted_at",
width: 250,
},
{
title: "Direction",
dataIndex: "direction",
key: "direction",
width: 150,
},
{
title: "From",
dataIndex: "from",
key: "from",
width: 200,
},
{
title: "To",
dataIndex: "to",
key: "to",
width: 200,
},
{
title: "Trunk",
dataIndex: "trunk",
key: "trunk",
width: 150,
},
{
title: "Duration",
dataIndex: "duration",
key: "duration",
width: 150,
},
];
//=============================================================================
// Get recent calls
//=============================================================================
const handleFilterChange = () => {
let filter = {
page: currentPage,
count: rowCount,
};
if (attemptedAt) {
switch (attemptedAt) {
case "today":
filter.start = moment().startOf("date").toISOString();
break;
case "7d":
filter.days = 7;
break;
case "14d":
filter.days = 14;
break;
case "30d":
filter.days = 30;
break;
default:
}
}
if (dirFilter === "inbound") {
filter.direction = "inbound";
} else if (dirFilter === "outbound") {
filter.direction = "outbound";
}
if (answered && answered !== "all") {
filter.answered = answered === "answered" ? "true" : "false";
}
getRecentCallsData(filter);
};
const getRecentCallsData = async (filter = {}) => {
let isMounted = true;
if (!account) {
setRecentCallsData([]);
setTotalCount(0);
return;
}
try {
setLoading(true);
const result = await axios({
method: "get",
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/Accounts/${account}/RecentCalls`,
headers: {
Authorization: `Bearer ${jwt}`,
},
params: {
...filter,
},
});
if (isMounted) {
const { total, data } = result.data;
setRawData([...data]);
const recentCalls = data.map((item, index) => ({
key: index,
...item,
attempted_at: item.attempted_at
? moment(item.attempted_at).format("YYYY MM.DD hh:mm a")
: "",
from: phoneNumberFormat(item.from),
to: phoneNumberFormat(item.to),
status: item.answered ? "answered" : item.termination_reason,
duration: timeFormat(item.duration),
}));
setRecentCallsData(recentCalls);
setTotalCount(total);
setExpandedRowKeys([]);
}
} catch (err) {
handleErrors({ err, history, dispatch });
} finally {
if (isMounted) {
setLoading(false);
}
}
};
const renderExpandedRow = (data) => {
const fields = [
"direction",
"attempted_at",
"answered_at",
"terminated_at",
"duration",
"answered",
"from",
"to",
"termination_reason",
"call_sid",
"sip_callid",
"host",
"remote_host",
"sip_status",
"trunk",
];
return (
<ExpandedSection>
{fields.map((field, index) => {
if (!rawData || !rawData[data.key]) {
return null;
}
let label = rawData[data.key][field];
if (typeof label === "boolean") {
label = label ? "true" : "false";
}
return (
<React.Fragment key={index}>
<Label>{`${field}:`}</Label>
<Label>{label}</Label>
</React.Fragment>
);
})}
</ExpandedSection>
);
};
const renderPagination = (page, type, originElement) => {
let node = originElement;
switch (type) {
case "page":
node = <StyledButton gray={currentPage !== page}>{page}</StyledButton>;
break;
case "prev":
node = <StyledButton>{`<`}</StyledButton>;
break;
case "next":
node = <StyledButton>{`>`}</StyledButton>;
break;
default:
}
return node;
};
const handleExpandChange = (expanded, record) => {
if (expanded) {
setExpandedRowKeys((prev) => [...prev, record.key]);
} else {
setExpandedRowKeys((prev) => [
...prev.filter((item) => item !== record.key),
]);
}
};
useEffect(() => {
if (currentPage === 1) {
handleFilterChange();
} else {
setCurrentPage(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account, attemptedAt, dirFilter, answered]);
useEffect(() => {
handleFilterChange();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, rowCount]);
useEffect(() => {
if (currentServiceProvider) {
const getAccounts = async () => {
let isMounted = true;
try {
setLoading(true);
const accountResponse = await axios({
method: "get",
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/ServiceProviders/${currentServiceProvider}/Accounts`,
headers: {
Authorization: `Bearer ${jwt}`,
},
});
setAccountList((accountResponse.data || []).sort((a, b) => a.name.localeCompare(b.name)));
if (accountResponse.data.length > 0) {
setAccount(accountResponse.data[0].account_sid);
} else {
setAccount("");
}
} catch (err) {
handleErrors({ err, history, dispatch });
} finally {
if (isMounted) {
setLoading(false);
}
}
};
getAccounts();
} else {
setAccountList([]);
}
}, [currentServiceProvider]);
//=============================================================================
// Render
//=============================================================================
return (
<InternalTemplate title="Recent Calls">
<StyledInputGroup flexEnd space>
<FilterLabel htmlFor="account">Account:</FilterLabel>
<AccountSelect
name="account"
id="account"
value={account}
onChange={(e) => setAccount(e.target.value)}
>
{accountList.map((acc) => (
<option key={acc.account_sid} value={acc.account_sid}>{acc.name}</option>
))}
</AccountSelect>
<FilterLabel htmlFor="daterange">Date:</FilterLabel>
<Select
name="daterange"
id="daterange"
value={attemptedAt}
onChange={(e) => setAttemptedAt(e.target.value)}
>
<option value="today">today</option>
<option value="7d">last 7d</option>
<option value="14d">last 14d</option>
<option value="30d">last 30d</option>
</Select>
<FilterLabel htmlFor="direction">Direction:</FilterLabel>
<Select
name="direction"
id="direction"
value={dirFilter}
onChange={(e) => setDirFilter(e.target.value)}
>
<option value="io">either</option>
<option value="inbound">inbound only</option>
<option value="outbound">outbound only</option>
</Select>
<FilterLabel htmlFor="status">Status:</FilterLabel>
<Select
name="status"
id="status"
value={answered}
onChange={(e) => setAnswered(e.target.value)}
>
<option value="all">all</option>
<option value="answered">answered</option>
<option value="not-answered">not answered</option>
</Select>
</StyledInputGroup>
<AntdTable
dataSource={recentCallsData}
columns={Columns}
rowKey="key"
loading={loading}
pagination={{
position: ["bottomCenter"],
onChange: (page, size) => {
setCurrentPage(page);
setRowCount(size);
},
showTotal: (total) => `Total: ${total} records`,
current: currentPage,
total: totalCount,
pageSize: rowCount,
pageSizeOptions: [25, 50, 100],
showSizeChanger: true,
itemRender: renderPagination,
showLessItems: true,
}}
scroll={{ y: Math.max(height - 580, 200) }}
expandable={{
expandedRowRender: renderExpandedRow,
}}
expandedRowKeys={expandedRowKeys}
onExpand={handleExpandChange}
/>
</InternalTemplate>
);
};
export default RecentCallsIndex;

View File

@@ -0,0 +1,31 @@
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import InternalTemplate from '../../templates/InternalTemplate';
import SpeechForm from '../../forms/SpeechForm';
import Sbcs from '../../blocks/Sbcs';
const SpeechServicesAddEdit = () => {
let { speech_service_sid } = useParams();
const pageTitle = speech_service_sid ? 'Edit Speech Service' : 'Add Speech Service';
useEffect(() => {
document.title = `${pageTitle} | Jambonz | Open Source CPAAS`;
});
return (
<InternalTemplate
type="form"
title={pageTitle}
subtitle={<Sbcs />}
breadcrumbs={[
{ name: 'Speech Services', url: '/internal/speech-services' },
{ name: pageTitle },
]}
>
<SpeechForm
type={speech_service_sid ? 'edit' : 'add'}
speech_service_sid={speech_service_sid}
/>
</InternalTemplate>
);
};
export default SpeechServicesAddEdit;

View File

@@ -0,0 +1,221 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useContext, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import axios from 'axios';
import { NotificationDispatchContext } from '../../../contexts/NotificationContext';
import handleErrors from '../../../helpers/handleErrors';
import InternalTemplate from '../../templates/InternalTemplate';
import TableContent from '../../../components/blocks/TableContent';
import { ServiceProviderValueContext } from '../../../contexts/ServiceProviderContext';
import Sbcs from '../../blocks/Sbcs';
const SpeechServicesList = () => {
let history = useHistory();
const dispatch = useContext(NotificationDispatchContext);
const currentServiceProvider = useContext(ServiceProviderValueContext);
//=============================================================================
// Get speech services
//=============================================================================
const getSpeechServices = useCallback(async () => {
const jwt = localStorage.getItem('token');
try {
if (!jwt) {
history.push('/');
dispatch({
type: 'ADD',
level: 'error',
message: 'You must log in to view that page.',
});
return;
}
if(!currentServiceProvider) return [];
const speechServices = await axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/ServiceProviders/${currentServiceProvider}/SpeechCredentials`,
headers: {
Authorization: `Bearer ${jwt}`,
},
});
const credentialTestPromises = speechServices.data.map(s => {
if (s.use_for_stt || s.use_for_tts) {
return axios({
method: 'get',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/ServiceProviders/${currentServiceProvider}/SpeechCredentials/${s.speech_credential_sid}/test`,
headers: {
Authorization: `Bearer ${jwt}`,
},
});
}
return null;
});
const testResposes = await Promise.all(credentialTestPromises);
const cleanedUpSpeechServices = speechServices.data.map((s, i) => {
const testResults = testResposes[i] && testResposes[i].data;
let content = null;
let title = null;
if (s.use_for_tts && s.use_for_stt) {
if (testResults.tts.status === 'ok' && testResults.stt.status === 'ok') {
content = 'ok';
title = 'Connection test successful';
} else {
content = 'fail';
if (testResults.tts.reason && testResults.stt.reason) {
if (testResults.tts.reason === testResults.stt.reason) {
title = testResults.tts.reason;
} else {
title = `TTS: ${testResults.tts.reason}. STT: ${testResults.stt.reason}`;
}
} else if (testResults.tts.reason) {
title = `TTS: ${testResults.tts.reason}`;
} else if (testResults.stt.reason) {
title = `STT: ${testResults.stt.reason}`;
}
}
} else if (s.use_for_tts) {
content = testResults.tts.status;
title = testResults.tts.status === 'ok'
? 'Connection test successful'
: testResults.tts.reason;
} else if (s.use_for_stt) {
content = testResults.stt.status;
title = testResults.stt.status === 'ok'
? 'Connection test successful'
: testResults.stt.reason;
}
const { last_used } = s;
let lastUsedString = 'Never used';
if (last_used) {
const currentDate = new Date();
const lastUsedDate = new Date(last_used);
currentDate.setHours(0,0,0,0);
lastUsedDate.setHours(0,0,0,0);
const daysDifference = Math.round((currentDate - lastUsedDate) / 1000 / 60 / 60 / 24);
lastUsedString = daysDifference > 1
? `${daysDifference} days ago`
: daysDifference === 1
? 'Yesterday'
: daysDifference === 0
? 'Today'
: 'Never used';
}
return {
sid: s.speech_credential_sid,
vendor: s.vendor,
usage: (s.use_for_tts && s.use_for_stt) ? 'TTS/STT'
: s.use_for_tts ? 'TTS'
: s.use_for_stt ? 'STT'
: 'Not in use',
last_used: lastUsedString,
status: {
type: 'status',
content,
title,
},
};
});
return(cleanedUpSpeechServices);
} catch (err) {
handleErrors({ err, history, dispatch, fallbackMessage: 'Unable to get speech services' });
}
}, [currentServiceProvider]);
//=============================================================================
// Delete speech service
//=============================================================================
const formatSpeechServiceToDelete = s => {
return [
{ name: 'Vendor', content: s.vendor || '[none]' },
{ name: 'Usage', content: s.usage || '[none]' },
{ name: 'Last Used', content: s.last_used || 'Never' },
];
};
const deleteSpeechService = useCallback(async speechServiceToDelete => {
const jwt = localStorage.getItem('token');
try {
if (!jwt) {
history.push('/');
dispatch({
type: 'ADD',
level: 'error',
message: 'You must log in to view that page.',
});
return;
}
// Delete speech service
await axios({
method: 'delete',
baseURL: process.env.REACT_APP_API_BASE_URL,
url: `/ServiceProviders/${currentServiceProvider}/SpeechCredentials/${speechServiceToDelete.sid}`,
headers: {
Authorization: `Bearer ${jwt}`,
},
});
return 'success';
} catch (err) {
if (err.response && err.response.status === 401) {
localStorage.clear();
sessionStorage.clear();
history.push('/');
dispatch({
type: 'ADD',
level: 'error',
message: 'Your session has expired. Please log in and try again.',
});
} else {
console.error(err.response || err);
return ((err.response && err.response.data && err.response.data.msg) || 'Unable to delete speech service');
}
}
}, [currentServiceProvider]);
//=============================================================================
// Render
//=============================================================================
return (
<InternalTemplate
type="normalTable"
title="Speech Services"
subtitle={<Sbcs />}
addButtonText="Add Speech Service"
addButtonLink="/internal/speech-services/add"
>
<TableContent
normalTable
name="speech service"
urlParam="speech-services"
getContent={getSpeechServices}
columns={[
{ header: 'Vendor', key: 'vendor', bold: true },
{ header: 'Usage', key: 'usage', },
{ header: 'Last Used', key: 'last_used', },
{ header: 'Status', key: 'status', textAlign: 'center' },
]}
formatContentToDelete={formatSpeechServiceToDelete}
deleteContent={deleteSpeechService}
/>
</InternalTemplate>
);
};
export default SpeechServicesList;

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import SetupTemplate from '../../templates/SetupTemplate';
import SipTrunkForm from '../../forms/SipTrunkForm';
import CarrierForm from '../../forms/CarrierForm';
import Sbcs from '../../blocks/Sbcs';
const ConfigureSipTrunk = () => {
@@ -14,7 +14,7 @@ const ConfigureSipTrunk = () => {
subtitle={<Sbcs centered />}
progress={3}
>
<SipTrunkForm
<CarrierForm
type="setup"
/>
</SetupTemplate>

View File

@@ -123,25 +123,25 @@ const CreatePassword = () => {
return;
}
const { sip_realm, registration_hook } = accounts[0];
// const { sip_realm, registration_hook } = accounts[0];
if (
(!sip_realm || !registration_hook) &&
!applications.length
) {
history.push('/configure-account');
return;
}
// if (
// (!sip_realm || !registration_hook) &&
// !applications.length
// ) {
// history.push('/configure-account');
// return;
// }
if (!applications.length) {
history.push('/create-application');
return;
}
// if (!applications.length) {
// history.push('/create-application');
// return;
// }
if (!voipCarriers.length) {
history.push('/configure-sip-trunk');
return;
}
// if (!voipCarriers.length) {
// history.push('/configure-sip-trunk');
// return;
// }
history.push('/internal/accounts');
}
@@ -241,6 +241,9 @@ const CreatePassword = () => {
old_password,
new_password: password,
},
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
}
});
sessionStorage.removeItem('user_sid');
@@ -250,7 +253,8 @@ const CreatePassword = () => {
localStorage.setItem('token', response.data.token);
}
history.push('/configure-account');
// history.push('/configure-account');
history.push('/internal/accounts');
} catch(err) {
console.log(err);

View File

@@ -0,0 +1,16 @@
import React, { createContext, useState } from "react";
export const ServiceProviderValueContext = createContext();
export const ServiceProviderMethodContext = createContext();
export function ServiceProvider(props) {
const [currentServiceProvider, setCurrentServiceProvider] = useState("");
return (
<ServiceProviderValueContext.Provider value={currentServiceProvider}>
<ServiceProviderMethodContext.Provider value={setCurrentServiceProvider}>
{props.children}
</ServiceProviderMethodContext.Provider>
</ServiceProviderValueContext.Provider>
);
}

View File

@@ -0,0 +1,10 @@
export default [
{ name: 'Australian English', 'code': 'en-AU' },
{ name: 'British English', 'code': 'en-GB' },
{ name: 'US English', 'code': 'en-US' },
{ name: 'French', 'code': 'fr-FR' },
{ name: 'Canadian French', 'code': 'fr-CA' },
{ name: 'German', 'code': 'de-DE' },
{ name: 'Italian', 'code': 'it-IT' },
{ name: 'US Spanish', 'code': 'es-US' },
];

View File

@@ -0,0 +1,41 @@
const handleErrors = ({ err, history, dispatch, redirect, setErrorMessage, fallbackMessage, preferFallback }) => {
const errorMessage = (err.response && err.response.data && err.response.data.msg)
|| (preferFallback && fallbackMessage)
|| err.message
|| fallbackMessage
|| 'Something went wrong, please try again.';
if (err.response && err.response.status === 401) {
localStorage.clear();
sessionStorage.clear();
history.push('/');
dispatch({
type: 'ADD',
level: 'error',
message: 'Your session has expired. Please sign in and try again.',
});
return;
}
if (setErrorMessage) {
setErrorMessage(errorMessage);
} else {
dispatch({
type: 'ADD',
level: 'error',
message: errorMessage,
});
}
if (process.env.NODE_ENV === 'development') {
console.error(err.response || err);
}
if (redirect) {
history.push(redirect);
}
};
export default handleErrors;

12
src/helpers/timeFormat.js Normal file
View File

@@ -0,0 +1,12 @@
const timeFormat = seconds => {
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
export default timeFormat;

View File

@@ -0,0 +1,3 @@
<svg width="21" height="19" viewBox="0 0 21 19" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.749 0.737865C11.1939 -0.245954 9.80608 -0.245955 9.25095 0.737863L0.19538 16.7864C-0.359751 17.7702 0.334161 19 1.44442 19H19.5556C20.6658 19 21.3598 17.7702 20.8046 16.7864L11.749 0.737865ZM11.5307 12.8922L12 10.367V7H9V10.367L9.5 12.8922H11.5307ZM11.5307 14.1799C11.2736 13.9958 10.9225 13.9037 10.4774 13.9037C10.0324 13.9037 9.68129 13.9958 9.42415 14.1799C9.17691 14.364 9.05328 14.6234 9.05328 14.9581C9.05328 15.2845 9.17691 15.5397 9.42415 15.7238C9.68129 15.9079 10.0324 16 10.4774 16C10.9225 16 11.2736 15.9079 11.5307 15.7238C11.7878 15.5397 11.9164 15.2845 11.9164 14.9581C11.9164 14.6234 11.7878 14.364 11.5307 14.1799Z" />
</svg>

After

Width:  |  Height:  |  Size: 781 B

View File

Before

Width:  |  Height:  |  Size: 827 B

After

Width:  |  Height:  |  Size: 827 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="43" viewBox="0 0 128 43">
<g fill="none" fill-rule="evenodd">
<g>
<g>
<path fill="#231F20" fill-rule="nonzero" d="M3.285 3.867c-.254.454-.352.98-.278 1.496.06.42.23.817.495 1.148.264.332.612.587 1.007.738.485.185 1.017.205 1.514.056.498-.149.933-.457 1.238-.879.305-.42.465-.931.453-1.452-.012-.521-.194-1.024-.518-1.43-.324-.408-.772-.696-1.276-.822-.503-.126-1.034-.082-1.51.125-.477.207-.872.565-1.125 1.02zM3.177 23.187c0 1.132-.642 1.833-1.555 1.833H0v3.36c.338.238 1.25.442 2.163.442 2.873 0 5.306-2.24 5.306-5.126V9.676H3.177v13.511zM25.671 19.722v3.53c-.36.17-.751.262-1.149.271-.833.014-1.655-.193-2.384-.597-.73-.405-1.34-.995-1.773-1.71-.535.726-1.234 1.315-2.04 1.716-.807.402-1.697.604-2.597.591-3.756 0-6.692-3.123-6.692-7.06 0-3.938 2.936-7.06 6.692-7.06 1.422-.023 2.8.499 3.852 1.46V9.673h4.293v8.724c0 1.131.608 1.324 1.183 1.324h.615zm-5.88-3.259c-.003-.557-.148-1.104-.422-1.588-.273-.485-.666-.89-1.14-1.179-.475-.288-1.016-.449-1.57-.467-.554-.018-1.104.107-1.596.364-.353.171-.667.412-.925.708s-.455.642-.577 1.015c-.165.444-.23.918-.192 1.389.037.471.178.929.41 1.34.234.41.554.765.938 1.039.384.273.824.458 1.287.542.463.084.94.065 1.395-.055.455-.121.878-.341 1.24-.644.361-.303.652-.682.852-1.11.2-.429.303-.896.303-1.37l-.003.016zM27.611 23.252V9.674h4.326v1.528c.462-.56 1.04-1.011 1.694-1.322.654-.31 1.368-.474 2.091-.477 1.851 0 3.44 1.04 4.274 2.635.188-.275.416-.593.626-.836.466-.54 1.04-1.011 1.694-1.322.654-.31 1.368-.474 2.092-.477 2.737 0 4.9 2.274 4.9 5.257v8.588h-4.326v-7.871c.012-.278-.033-.556-.132-.817-.098-.26-.248-.497-.441-.697-.193-.2-.425-.359-.68-.466-.256-.108-.531-.162-.809-.159-.61.014-1.192.266-1.621.703-.421.43-.662 1.003-.676 1.604v7.703h-4.326v-7.871c.012-.278-.033-.556-.132-.817-.098-.26-.248-.497-.442-.697-.192-.2-.424-.359-.68-.466-.255-.108-.53-.162-.808-.159-.61.014-1.192.266-1.621.703-.43.438-.672 1.026-.677 1.64v7.67h-4.326z" transform="translate(-656 -16) translate(656 16)"/>
<path fill="#231F20" d="M55.95 22.081v1.17h-4.326V0h4.326v10.86c1.052-.959 2.429-1.48 3.85-1.457 3.756 0 6.692 3.122 6.692 7.06 0 3.937-2.936 7.06-6.692 7.06-.9.013-1.79-.19-2.596-.59-.456-.228-.877-.514-1.254-.852zm.209-7.206c-.274.484-.419 1.031-.422 1.588l-.003-.015c0 .473.104.94.303 1.368.2.429.49.808.852 1.11.362.304.785.524 1.24.645.456.12.932.14 1.395.055.463-.084.903-.269 1.287-.542.384-.274.705-.628.937-1.04.234-.41.374-.868.412-1.339.037-.471-.028-.945-.193-1.389-.122-.373-.319-.719-.577-1.015-.258-.296-.572-.537-.925-.708-.492-.257-1.042-.382-1.596-.364-.554.018-1.095.18-1.57.467-.474.288-.867.694-1.14 1.179z" transform="translate(-656 -16) translate(656 16)"/>
<path fill="#231F20" fill-rule="nonzero" d="M68.057 16.463c0-4.073 3.004-7.06 7.536-7.06 4.533 0 7.51 2.987 7.51 7.06 0 4.073-2.974 7.06-7.51 7.06s-7.536-2.987-7.536-7.06zm10.747 0c0-.638-.189-1.261-.541-1.792-.353-.53-.854-.943-1.441-1.187-.587-.244-1.232-.308-1.855-.184-.623.125-1.195.432-1.644.883-.45.45-.755 1.025-.879 1.65-.124.626-.06 1.275.183 1.864.243.59.654 1.093 1.182 1.447.528.355 1.15.544 1.784.544.423.004.842-.076 1.233-.237.39-.16.746-.398 1.044-.698.298-.3.533-.658.692-1.051.158-.394.237-.815.23-1.239h.012zM84.668 9.673v13.579h4.326V15.58c.005-.613.247-1.201.676-1.639.43-.437 1.011-.689 1.622-.703.277-.003.552.051.808.159.256.107.488.266.68.466.193.2.344.437.442.697.099.26.144.539.132.817v7.871h4.326V14.66c0-2.983-2.163-5.257-4.9-5.257-.724.003-1.438.166-2.092.477-.654.311-1.232.762-1.694 1.322v-1.53h-4.326z" transform="translate(-656 -16) translate(656 16)"/>
<path fill="#DA1C5C" d="M108.45 13.255c-.344-.377-.977-.762-2.123-.722-2.3.081-3.477 2.276-3.208 3.556.214 1.019-.435 2.02-1.45 2.234-1.014.216-2.01-.436-2.225-1.455-.729-3.47 2.017-7.937 6.751-8.104 2.146-.076 3.872.682 5.022 1.94 1.11 1.214 1.596 2.805 1.465 4.255-.26 2.87-2.463 4.395-4.03 5.389l-.072.045c1.532.41 2.903 1.164 4.066 2.184 1.606 1.409 2.763 3.276 3.432 5.333 2.25.886 4.468 2.098 6.487 3.668.28-1.03.958-2.396 1.58-2.91 1.685 3.234 2.775 7.954 3.16 11.524-2.345-1.133-6.738-2.564-9.686-2.982.566-1.1 1.718-2.167 2.61-2.678-1.105-.855-2.285-1.588-3.501-2.204-.132 2.544-1.31 4.774-2.904 6.418-1.813 1.87-4.31 3.135-6.802 3.251-2.319.108-4.506-.512-6.137-1.86-1.663-1.376-2.615-3.408-2.556-5.78.057-2.289.995-4.49 2.728-6.064 1.748-1.587 4.19-2.432 7.064-2.222.983.071 2.002.206 3.041.408-.298-.392-.628-.748-.986-1.062-1.525-1.337-3.668-2.024-6.418-1.326-.064.016-.128.029-.192.038-.961.192-1.828.149-2.165-.381-.416-.654-.437-1.518.518-2.809.637-.913 1.447-1.609 2.22-2.168.6-.434 1.28-.853 1.876-1.22.226-.139.44-.27.633-.393 1.624-1.03 2.216-1.68 2.294-2.54.037-.405-.106-.942-.491-1.363zm4.451 17.537c-1.726-.527-3.445-.842-5.052-.96-1.963-.143-3.365.434-4.274 1.26-.925.839-1.459 2.041-1.492 3.36-.03 1.237.437 2.15 1.19 2.773.783.648 2.005 1.078 3.575 1.004 1.397-.064 3.03-.819 4.286-2.114 1.237-1.276 1.965-2.925 1.836-4.669-.016-.22-.04-.438-.069-.654z" transform="translate(-656 -16) translate(656 16)"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,10 @@
<svg width="20" height="18" viewBox="0 0 20 18" xmlns="http://www.w3.org/2000/svg">
<path d="M5 0H0V3H5V0Z" />
<path d="M5 10H0V13H5V10Z" />
<path d="M0 5H5V8H0V5Z" />
<path d="M5 15H0V18H5V15Z" />
<path d="M7 0H20V3H7V0Z" />
<path d="M20 10H7V13H20V10Z" />
<path d="M7 5H20V8H7V5Z" />
<path d="M20 15H7V18H20V15Z" />
</svg>

After

Width:  |  Height:  |  Size: 325 B

View File

@@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18C11.1712 18 12.281 17.749 13.2809 17.2987C13.5009 17.1997 13.7493 17.1835 13.9802 17.2531C15.0582 17.5782 16.1613 17.7632 17.0663 17.8677C16.7653 17.2512 16.4715 16.4797 16.2688 15.5535C16.2071 15.272 16.2699 14.9776 16.4411 14.7457C17.4211 13.4182 18 11.7781 18 10C18 5.58172 14.4183 2 10 2ZM19 19C19 20 18.9997 20 18.9997 20L18.9957 20L18.9875 20L18.9597 19.9998C18.9361 19.9995 18.9025 19.9991 18.8597 19.9983C18.7741 19.9966 18.6512 19.9933 18.4967 19.9868C18.1878 19.9738 17.7507 19.9479 17.2293 19.8965C16.2798 19.8028 15.0295 19.6221 13.7566 19.2701C12.5956 19.741 11.3269 20 10 20C4.47715 20 0 15.5228 0 10C0 4.47715 4.47715 0 10 0C15.5228 0 20 4.47715 20 10C20 12.0489 19.3828 13.9562 18.3244 15.5429C18.5644 16.4315 18.897 17.105 19.1771 17.5645C19.3383 17.8291 19.482 18.0224 19.5801 18.1443C19.6291 18.2052 19.6665 18.248 19.6889 18.2729C19.7001 18.2853 19.7076 18.2932 19.7107 18.2965L19.709 18.2948L19.7081 18.2939C19.7096 18.2954 19.7105 18.2963 19.7119 18.2977C19.9941 18.5837 20.0778 19.0111 19.9239 19.3827C19.7691 19.7564 19.4041 20 18.9997 20L19 19ZM19.7119 18.2977V18.2977Z" />
<path d="M6 5H14V7H6V5Z" />
<path d="M4 9H16V11H4V9Z" />
<path d="M6 13H14V15H6V13Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -38,6 +38,7 @@ body {
font-family: WorkSans, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -1,16 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import { NotificationProvider } from './contexts/NotificationContext';
import { ModalProvider } from './contexts/ModalContext';
import { ShowMsTeamsProvider } from './contexts/ShowMsTeamsContext';
import { ServiceProvider } from './contexts/ServiceProviderContext';
import App from './App';
import "antd/dist/antd.css";
import './index.css';
ReactDOM.render(
<NotificationProvider>
<ModalProvider>
<ShowMsTeamsProvider>
<App />
<ServiceProvider>
<App />
</ServiceProvider>
</ShowMsTeamsProvider>
</ModalProvider>
</NotificationProvider>,