Compare commits

...

16 Commits

Author SHA1 Message Date
Brandon Lee Kitajchuk
f42052a04e Fix useScopedRedirect hook (#164)
* Fix useScopedRedirect hook

* Refactor user UI

* Move user-me to own directory
2022-12-10 11:58:53 -05:00
eglehelms
fd749e1a2e fix user name 2022-12-09 14:37:27 +01:00
Brandon Lee Kitajchuk
2f868d955d Refactor how we manage scope for navi (#163) 2022-12-09 11:52:44 +01:00
eglehelms
f5995ce19d useScopedRedirect 2022-12-08 14:33:23 +01:00
eglehelms
27cc99287d discussed changes 2022-12-07 18:10:52 +01:00
eglehelms
bb460c82ef apply review comments 2022-12-07 15:17:20 +01:00
eglehelms
c54595c99c fix routing 2022-12-05 16:47:21 +01:00
eglehelms
aacebae373 apply 1st review comments 2022-12-05 16:10:38 +01:00
eglehelms
5a363d70ff resolve conflict from main branch 2022-12-05 12:22:46 +01:00
eglehelms
a405b3e53b add restrictions to applications and accounts 2022-12-01 15:46:16 +01:00
eglehelms
93df766eb5 fix access conditionals 2022-12-01 12:44:36 +01:00
eglehelms
e35c422e89 faulty checks 2022-11-30 22:06:59 +01:00
eglehelms
70c1c416a2 check reroute when logging in 2022-11-30 19:42:39 +01:00
eglehelms
ccefb37f20 add filtering to speech creds 2022-11-30 19:34:37 +01:00
eglehelms
c2c4a28868 post speech to a different path if scoped user 2022-11-30 19:34:19 +01:00
eglehelms
92afbfb388 limitations to carriers and speech 2022-11-30 19:32:54 +01:00
35 changed files with 833 additions and 215 deletions

View File

@@ -17,6 +17,7 @@ import {
API_SMPP_GATEWAY,
API_SIP_GATEWAY,
API_PASSWORD_SETTINGS,
USER_ACCOUNT,
} from "./constants";
import { ROUTE_LOGIN } from "src/router/routes";
import {
@@ -58,6 +59,7 @@ import type {
LimitCategories,
PasswordSettings,
} from "./types";
import { UserData } from "../store/types";
import { StatusCodes } from "./types";
/** Wrap all requests to normalize response handling */
@@ -257,13 +259,21 @@ export const postApplication = (payload: Partial<Application>) => {
};
export const postSpeechService = (
user: UserData,
sid: string,
payload: Partial<SpeechCredential>
) => {
return postFetch<SidResponse, Partial<SpeechCredential>>(
`${API_SERVICE_PROVIDERS}/${sid}/SpeechCredentials`,
payload
);
if (user.scope === USER_ACCOUNT) {
return postFetch<SidResponse, Partial<SpeechCredential>>(
`${API_ACCOUNTS}/${user.account_sid}/SpeechCredentials`,
payload
);
} else {
return postFetch<SidResponse, Partial<SpeechCredential>>(
`${API_SERVICE_PROVIDERS}/${sid}/SpeechCredentials`,
payload
);
}
};
export const postMsTeamsTentant = (payload: Partial<MSTeamsTenant>) => {
@@ -280,11 +290,22 @@ export const postPhoneNumber = (payload: Partial<PhoneNumber>) => {
);
};
export const postCarrier = (sid: string, payload: Partial<Carrier>) => {
return postFetch<SidResponse, Partial<Carrier>>(
`${API_SERVICE_PROVIDERS}/${sid}/VoipCarriers/`,
payload
);
export const postCarrier = (
user: UserData,
sid: string,
payload: Partial<Carrier>
) => {
if (user.scope === USER_ACCOUNT) {
return postFetch<SidResponse, Partial<SpeechCredential>>(
`${API_ACCOUNTS}/${user.account_sid}/VoipCarriers/`,
payload
);
} else {
return postFetch<SidResponse, Partial<Carrier>>(
`${API_SERVICE_PROVIDERS}/${sid}/VoipCarriers/`,
payload
);
}
};
export const postPredefinedCarrierTemplate = (
@@ -296,6 +317,15 @@ export const postPredefinedCarrierTemplate = (
);
};
export const postPredefinedCarrierTemplateAccount = (
accountSid: string,
predefinedCarrierSid: string
) => {
return postFetch<SidResponse>(
`${API_BASE_URL}/Accounts/${accountSid}/PredefinedCarriers/${predefinedCarrierSid}`
);
};
export const postSipGateway = (payload: Partial<SipGateway>) => {
return postFetch<SidResponse, Partial<SipGateway>>(API_SIP_GATEWAY, payload);
};
@@ -364,14 +394,22 @@ export const putApplication = (sid: string, payload: Partial<Application>) => {
};
export const putSpeechService = (
user: UserData,
sid1: string,
sid2: string,
payload: Partial<SpeechCredential>
) => {
return putFetch<EmptyResponse, Partial<SpeechCredential>>(
`${API_SERVICE_PROVIDERS}/${sid1}/SpeechCredentials/${sid2}`,
payload
);
if (user.scope === USER_ACCOUNT) {
return putFetch<EmptyResponse, Partial<SpeechCredential>>(
`${API_ACCOUNTS}/${user.account_sid}/SpeechCredentials/${sid2}`,
payload
);
} else {
return putFetch<EmptyResponse, Partial<SpeechCredential>>(
`${API_SERVICE_PROVIDERS}/${sid1}/SpeechCredentials/${sid2}`,
payload
);
}
};
export const putMsTeamsTenant = (

View File

@@ -106,7 +106,9 @@ export interface User {
is_active: boolean;
force_change: boolean;
account_sid: string | null;
account_name?: string | null;
service_provider_sid: string | null;
service_provider_name?: string | null;
initial_password?: string;
permissions?: UserPermissions[];
}
@@ -142,6 +144,11 @@ export interface UserJWT {
account_sid?: string | null;
service_provider_sid?: string | null;
permissions: UserPermissions[];
name: string;
}
export interface CurrentUserData {
user: User;
}
export interface ServiceProvider {

View File

@@ -5,6 +5,7 @@ import {
Menu,
List,
Edit,
User,
Plus,
Grid,
Phone,
@@ -53,6 +54,7 @@ export const Icons: IconMap = {
Menu,
List,
Edit,
User,
Plus,
Grid,
Phone,

View File

@@ -2,6 +2,7 @@ import React, { useState } from "react";
import { Outlet } from "react-router-dom";
import { Button, Icon, classNames } from "jambonz-ui";
import { UserMe } from "./user-me";
import { Navi } from "./navi";
import { Icons } from "src/components";
import { toastSuccess } from "src/store";
@@ -43,6 +44,7 @@ export const Layout = () => {
<Icon subStyle="dark" onClick={handleMenu}>
<Icons.Menu />
</Icon>
<UserMe />
<Button
small
mainStyle="hollow"

View File

@@ -4,6 +4,7 @@ import { Link, useLocation } from "react-router-dom";
import { Icons, ModalForm } from "src/components";
import { naviTop, naviByo } from "./items";
import { UserMe } from "../user-me";
import {
useSelectState,
useDispatch,
@@ -16,7 +17,7 @@ import type { NaviItem } from "./items";
import "./styles.scss";
import { ScopedAccess } from "src/components/scoped-access";
import { Scope } from "src/store/types";
import { Scope, UserData } from "src/store/types";
type CommonProps = {
handleMenu: () => void;
@@ -30,16 +31,17 @@ type NaviProps = CommonProps & {
type ItemProps = CommonProps & {
item: NaviItem;
user?: UserData;
};
const Item = ({ item, handleMenu }: ItemProps) => {
const Item = ({ item, user, handleMenu }: ItemProps) => {
const location = useLocation();
const active = location.pathname.includes(item.route);
const active = location.pathname.includes(item.route(user));
return (
<li>
<Link
to={item.route}
to={item.route(user)}
className={classNames({ navi__link: true, "txt--jean": true, active })}
onClick={handleMenu}
>
@@ -71,6 +73,20 @@ export const Navi = ({
);
}, [accessControl, currentServiceProvider]);
const naviTopFiltered = useMemo(() => {
return naviTop.filter((item) => {
if (item.scope === undefined) {
return true;
} else if (user) {
if (item.restrict) {
return user.access === item.scope;
}
return user.access >= item.scope;
}
});
}, [user]);
const handleSubmit = () => {
postServiceProviders({ name })
.then(({ json }) => {
@@ -121,6 +137,7 @@ export const Navi = ({
<Icon subStyle="white" onClick={handleMenu}>
<Icons.X />
</Icon>
<UserMe />
<Button
small
mainStyle="hollow"
@@ -172,9 +189,16 @@ export const Navi = ({
</div>
<div className="navi__routes">
<ul>
{naviTop.map((item) => (
<Item key={item.label} item={item} handleMenu={handleMenu} />
))}
{naviTopFiltered.map((item) => {
return (
<Item
key={item.label}
user={user}
item={item}
handleMenu={handleMenu}
/>
);
})}
</ul>
</div>
<div className="navi__byo">
@@ -183,7 +207,12 @@ export const Navi = ({
<div className="navi__routes">
<ul>
{naviByoFiltered.map((item) => (
<Item key={item.label} item={item} handleMenu={handleMenu} />
<Item
key={item.label}
user={user}
item={item}
handleMenu={handleMenu}
/>
))}
</ul>
</div>

View File

@@ -11,6 +11,7 @@ import {
ROUTE_INTERNAL_MS_TEAMS_TENANTS,
} from "src/router/routes";
import { Icons } from "src/components";
import { Scope, UserData } from "src/store/types";
import type { Icon } from "react-feather";
import type { ACL } from "src/store/types";
@@ -18,40 +19,51 @@ import type { ACL } from "src/store/types";
export interface NaviItem {
label: string;
icon: Icon;
route: string;
route: (user?: UserData) => string;
acl?: keyof ACL;
scope?: Scope;
restrict?: boolean;
}
export const naviTop: NaviItem[] = [
{
label: "Users",
icon: Icons.UserCheck,
route: ROUTE_INTERNAL_USERS,
route: () => ROUTE_INTERNAL_USERS,
},
{
label: "Settings",
icon: Icons.Settings,
route: ROUTE_INTERNAL_SETTINGS,
route: () => ROUTE_INTERNAL_SETTINGS,
scope: Scope.service_provider,
},
{
label: "Accounts",
icon: Icons.Activity,
route: ROUTE_INTERNAL_ACCOUNTS,
route: () => ROUTE_INTERNAL_ACCOUNTS,
scope: Scope.service_provider,
},
{
label: "Account",
icon: Icons.Activity,
route: (user) => `${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/edit`,
scope: Scope.account,
restrict: true,
},
{
label: "Applications",
icon: Icons.Grid,
route: ROUTE_INTERNAL_APPLICATIONS,
route: () => ROUTE_INTERNAL_APPLICATIONS,
},
{
label: "Recent Calls",
icon: Icons.List,
route: ROUTE_INTERNAL_RECENT_CALLS,
route: () => ROUTE_INTERNAL_RECENT_CALLS,
},
{
label: "Alerts",
icon: Icons.AlertCircle,
route: ROUTE_INTERNAL_ALERTS,
route: () => ROUTE_INTERNAL_ALERTS,
},
];
@@ -59,22 +71,22 @@ export const naviByo: NaviItem[] = [
{
label: "Carriers",
icon: Icons.Server,
route: ROUTE_INTERNAL_CARRIERS,
route: () => ROUTE_INTERNAL_CARRIERS,
},
{
label: "Speech",
icon: Icons.MessageCircle,
route: ROUTE_INTERNAL_SPEECH,
route: () => ROUTE_INTERNAL_SPEECH,
},
{
label: "Phone Numbers",
icon: Icons.Phone,
route: ROUTE_INTERNAL_PHONE_NUMBERS,
route: () => ROUTE_INTERNAL_PHONE_NUMBERS,
},
{
label: "MS Teams Tenants",
icon: Icons.Users,
route: ROUTE_INTERNAL_MS_TEAMS_TENANTS,
route: () => ROUTE_INTERNAL_MS_TEAMS_TENANTS,
acl: "hasMSTeamsFqdn",
},
];

View File

@@ -58,6 +58,10 @@
.ico {
@include mixins.icosize();
}
.btn {
margin-left: auto;
}
}
&__sps {

View File

@@ -10,7 +10,6 @@
header {
padding: ui-vars.$px03;
display: flex;
justify-content: flex-end;
align-items: center;
@include mixins.mobile() {
@@ -30,6 +29,10 @@
display: flex;
}
}
.btn {
margin-left: auto;
}
}
main {

View File

@@ -0,0 +1,34 @@
import React from "react";
import { Link } from "react-router-dom";
import { Icons } from "src/components";
import { ROUTE_INTERNAL_USERS } from "src/router/routes";
import { useApiData } from "src/api";
import { useSelectState } from "src/store";
import type { CurrentUserData } from "src/api/types";
import "./styles.scss";
export const UserMe = () => {
const user = useSelectState("user");
const [userData] = useApiData<CurrentUserData>("Users/me");
return (
<div className="user">
<Icons.User className="user__icon" />
<div className="user__info">
<Link
to={`${ROUTE_INTERNAL_USERS}/${user?.user_sid}/edit`}
title="Edit user"
className="user__name"
>
<strong>{userData?.user.name}</strong>
</Link>
<div className="user__scope">
<strong>Scope:</strong> {user?.scope}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,42 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
/** User layout **/
.user {
display: flex;
align-items: center;
justify-content: space-between;
font-size: ui-vars.$mxs-size;
line-height: 0.4rem;
@include mixins.mobile() {
padding-left: ui-vars.$px01;
}
&__icon {
color: ui-vars.$jambonz;
width: ui-vars.$h3-size;
height: ui-vars.$h3-size;
padding-right: ui-vars.$px00;
.navi & {
color: ui-vars.$white;
}
}
&__name {
.navi & {
color: ui-vars.$white;
}
}
&__scope {
padding-top: ui-vars.$px01;
.navi & {
color: vars.$jeangrey;
}
}
}

View File

@@ -21,7 +21,11 @@ import {
LocalLimits,
} from "src/components/forms";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import { DEFAULT_WEBHOOK, WEBHOOK_METHODS } from "src/api/constants";
import {
DEFAULT_WEBHOOK,
USER_ACCOUNT,
WEBHOOK_METHODS,
} from "src/api/constants";
import { MSG_REQUIRED_FIELDS, MSG_WEBHOOK_FIELDS } from "src/constants";
import type {
@@ -42,6 +46,7 @@ type AccountFormProps = {
export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
const navigate = useNavigate();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useApiData<Account[]>("Accounts");
const [name, setName] = useState("");
@@ -137,6 +142,14 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (
user?.scope === USER_ACCOUNT &&
user.account_sid !== account?.data?.account_sid
) {
toastError("You do not have permissions to make changes to this Account");
return;
}
setMessage("");
if (accounts) {
@@ -173,6 +186,9 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
.then(() => {
account.refetch();
toastSuccess("Account updated successfully");
if (user?.scope !== USER_ACCOUNT) {
navigate(ROUTE_INTERNAL_ACCOUNTS);
}
})
.catch((error) => {
toastError(error.msg);

View File

@@ -6,20 +6,45 @@ import { useServiceProviderData, deleteAccount } from "src/api";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import { Section, Icons, Spinner, SearchFilter } from "src/components";
import { DeleteAccount } from "./delete";
import { toastError, toastSuccess } from "src/store";
import { hasLength, hasValue, useFilteredResults } from "src/utils";
import { toastError, toastSuccess, useSelectState } from "src/store";
import {
hasLength,
hasValue,
useFilteredResults,
useScopedRedirect,
} from "src/utils";
import { USER_ACCOUNT } from "src/api/constants";
import { Scope } from "src/store/types";
import type { Account } from "src/api/types";
export const Accounts = () => {
const user = useSelectState("user");
const [accounts, refetch] = useServiceProviderData<Account[]>("Accounts");
const [account, setAccount] = useState<Account | null>(null);
const [filter, setFilter] = useState("");
const filteredAccounts = useFilteredResults<Account>(filter, accounts);
useScopedRedirect(
Scope.service_provider,
`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/edit`,
user,
"You do not have permissions to manage all accounts"
);
const handleDelete = () => {
if (account) {
if (
user?.scope === USER_ACCOUNT &&
user.account_sid !== account.account_sid
) {
toastError(
"You do not have permissions to make changes to this Account"
);
return;
}
deleteAccount(account.account_sid)
.then(() => {
refetch();

View File

@@ -3,8 +3,12 @@ import { ButtonGroup, H1, M, MS } from "jambonz-ui";
import dayjs from "dayjs";
import { getAlerts, useServiceProviderData } from "src/api";
import { DATE_SELECTION, PER_PAGE_SELECTION } from "src/api/constants";
import { toastError } from "src/store";
import {
DATE_SELECTION,
PER_PAGE_SELECTION,
USER_ACCOUNT,
} from "src/api/constants";
import { toastError, useSelectState } from "src/store";
import { hasLength, hasValue } from "src/utils";
import {
AccountFilter,
@@ -18,6 +22,7 @@ import {
import type { Account, Alert, PageQuery } from "src/api/types";
export const Alerts = () => {
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
const [dateFilter, setDateFilter] = useState("today");
@@ -69,7 +74,13 @@ export const Alerts = () => {
<section className="filters filters--multi">
<AccountFilter
account={[accountSid, setAccountSid]}
accounts={accounts}
accounts={
user?.scope === USER_ACCOUNT
? accounts?.filter(
(acct) => acct.account_sid === user.account_sid
)
: accounts
}
/>
<SelectFilter
id="date_filter"
@@ -103,7 +114,7 @@ export const Alerts = () => {
</div>
))
) : (
<M>No data</M>
<M>No data.</M>
)}
</div>
</Section>

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { Button, ButtonGroup, MS } from "jambonz-ui";
import { Link, useNavigate } from "react-router-dom";
import { toastError, toastSuccess } from "src/store";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { ClipBoard, Section } from "src/components";
import {
Selector,
@@ -31,7 +31,11 @@ import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_APPLICATIONS,
} from "src/router/routes";
import { DEFAULT_WEBHOOK, WEBHOOK_METHODS } from "src/api/constants";
import {
DEFAULT_WEBHOOK,
USER_ACCOUNT,
WEBHOOK_METHODS,
} from "src/api/constants";
import type {
RecognizerVendors,
@@ -58,6 +62,7 @@ type ApplicationFormProps = {
export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const navigate = useNavigate();
const { synthesis, recognizers } = useSpeechVendors();
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useApiData<Application[]>("Applications");
const [applicationName, setApplicationName] = useState("");
@@ -115,6 +120,13 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (user?.scope === USER_ACCOUNT && user.account_sid !== accountSid) {
toastError(
"You do not have permissions to make changes to these Speech Credentials"
);
return;
}
setMessage("");
if (applications) {
@@ -152,6 +164,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
.then(() => {
application.refetch();
toastSuccess("Application updated successfully");
navigate(`${ROUTE_INTERNAL_APPLICATIONS}`);
})
.catch((error) => {
toastError(error.msg);
@@ -266,7 +279,13 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
</fieldset>
<fieldset>
<AccountSelect
accounts={accounts}
accounts={
user?.scope === USER_ACCOUNT
? accounts?.filter(
(acct) => acct.account_sid === user.account_sid
)
: accounts
}
account={[accountSid, setAccountSid]}
/>
</fieldset>

View File

@@ -3,7 +3,7 @@ import { H1, M, Button, Icon } from "jambonz-ui";
import { Link } from "react-router-dom";
import { deleteApplication, getFetch, useServiceProviderData } from "src/api";
import { API_ACCOUNTS } from "src/api/constants";
import { API_ACCOUNTS, USER_ACCOUNT } from "src/api/constants";
import {
ROUTE_INTERNAL_APPLICATIONS,
ROUTE_INTERNAL_ACCOUNTS,
@@ -22,6 +22,7 @@ import { hasLength, hasValue, useFilteredResults } from "src/utils";
import type { Application, Account } from "src/api/types";
export const Applications = () => {
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
@@ -44,6 +45,12 @@ export const Applications = () => {
const handleDelete = () => {
if (application) {
if (user?.scope === USER_ACCOUNT && user.account_sid !== accountSid) {
toastError(
"You do not have permissions to make changes to this Application"
);
return;
}
deleteApplication(application.application_sid)
.then(() => {
getApplications();
@@ -97,7 +104,13 @@ export const Applications = () => {
/>
<AccountFilter
account={[accountSid, setAccountSid]}
accounts={accounts}
accounts={
user?.scope === USER_ACCOUNT
? accounts?.filter(
(acct) => acct.account_sid === user.account_sid
)
: accounts
}
/>
</section>
<Section {...(hasLength(filteredApplications) && { slim: true })}>

View File

@@ -14,6 +14,7 @@ import {
useApiData,
useServiceProviderData,
postPredefinedCarrierTemplate,
postPredefinedCarrierTemplateAccount,
} from "src/api";
import {
DEFAULT_SIP_GATEWAY,
@@ -24,6 +25,7 @@ import {
NETMASK_OPTIONS,
TCP_MAX_PORT,
TECH_PREFIX_MINLENGTH,
USER_ACCOUNT,
} from "src/api/constants";
import { Icons, Section } from "src/components";
import {
@@ -63,6 +65,7 @@ export const CarrierForm = ({
carrierSmppGateways,
}: CarrierFormProps) => {
const navigate = useNavigate();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const refSipIp = useRef<HTMLInputElement[]>([]);
@@ -437,6 +440,14 @@ export const CarrierForm = ({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (
user?.scope === USER_ACCOUNT &&
(user.account_sid !== accountSid || !accountSid)
) {
toastError("You do not have permissions to make changes to this Carrier");
return;
}
setSipMessage("");
setSmppInboundMessage("");
setSmppOutboundMessage("");
@@ -485,7 +496,7 @@ export const CarrierForm = ({
smpp_inbound_password: smppInboundPass.trim() || null,
};
if (carrier && carrier.data) {
if (carrier && carrier.data && user) {
putCarrier(
currentServiceProvider.service_provider_sid,
carrier.data.voip_carrier_sid,
@@ -499,25 +510,27 @@ export const CarrierForm = ({
toastSuccess("Carrier updated successfully");
carrier.refetch();
navigate(ROUTE_INTERNAL_CARRIERS);
})
.catch((error) => {
toastError(error.msg);
});
} else {
postCarrier(currentServiceProvider.service_provider_sid, {
...carrierPayload,
service_provider_sid: currentServiceProvider.service_provider_sid,
})
.then(({ json }) => {
handleSipGatewayPutPost(json.sid);
handleSmppGatewayPutPost(json.sid);
toastSuccess("Carrier created successfully");
navigate(`${ROUTE_INTERNAL_CARRIERS}/${json.sid}/edit`);
if (user)
postCarrier(user, currentServiceProvider.service_provider_sid, {
...carrierPayload,
service_provider_sid: currentServiceProvider.service_provider_sid,
})
.catch((error) => {
toastError(error.msg);
});
.then(({ json }) => {
handleSipGatewayPutPost(json.sid);
handleSmppGatewayPutPost(json.sid);
toastSuccess("Carrier created successfully");
navigate(`${ROUTE_INTERNAL_CARRIERS}/${json.sid}/edit`);
})
.catch((error) => {
toastError(error.msg);
});
}
}
};
@@ -529,16 +542,26 @@ export const CarrierForm = ({
)?.predefined_carrier_sid;
if (currentServiceProvider && predefinedCarrierSid) {
postPredefinedCarrierTemplate(
currentServiceProvider.service_provider_sid,
predefinedCarrierSid
)
.then(({ json }) => {
navigate(`${ROUTE_INTERNAL_CARRIERS}/${json.sid}/edit`);
})
.catch((error) => {
toastError(error.msg);
});
if (user?.scope === USER_ACCOUNT) {
postPredefinedCarrierTemplateAccount(accountSid, predefinedCarrierSid)
.then(({ json }) => {
navigate(`${ROUTE_INTERNAL_CARRIERS}/${json.sid}/edit`);
})
.catch((error) => {
toastError(error.msg);
});
} else {
postPredefinedCarrierTemplate(
currentServiceProvider.service_provider_sid,
predefinedCarrierSid
)
.then(({ json }) => {
navigate(`${ROUTE_INTERNAL_CARRIERS}/${json.sid}/edit`);
})
.catch((error) => {
toastError(error.msg);
});
}
}
}
}, [predefinedName]);
@@ -583,6 +606,21 @@ export const CarrierForm = ({
}
}
const checkOptions = () => {
if (user?.scope === USER_ACCOUNT) {
if (carrier && carrier.data?.account_sid) {
return false;
}
if (!carrier) {
return false;
}
if (carrier && !carrier.data?.account_sid) {
return true;
}
}
return true;
};
return (
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
@@ -677,11 +715,24 @@ export const CarrierForm = ({
<em>Prepend a leading + on origination attempts.</em>
</MXS>
<AccountSelect
accounts={accounts}
accounts={
user?.scope === USER_ACCOUNT
? accounts?.filter(
(acct) => user.account_sid === acct.account_sid
)
: accounts
}
account={[accountSid, setAccountSid]}
label="Used By"
required={false}
defaultOption
defaultOption={checkOptions()}
disabled={
user?.scope !== USER_ACCOUNT
? false
: user.account_sid !== accountSid
? true
: false
}
/>
{accountSid && hasLength(applications) && (
<>
@@ -971,6 +1022,7 @@ export const CarrierForm = ({
</label>
</div>
</div>
<button
className="btnty"
title="Delete SIP Gateway"

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useEffect } from "react";
import { Link } from "react-router-dom";
import { Button, H1, Icon, M } from "jambonz-ui";
import {
@@ -8,7 +8,7 @@ import {
getFetch,
useServiceProviderData,
} from "src/api";
import { toastSuccess, toastError } from "src/store";
import { toastSuccess, toastError, useSelectState } from "src/store";
import { ROUTE_INTERNAL_CARRIERS } from "src/router/routes";
import {
AccountFilter,
@@ -17,16 +17,26 @@ import {
Spinner,
SearchFilter,
} from "src/components";
import { ScopedAccess } from "src/components/scoped-access";
import { Gateways } from "./gateways";
import { hasLength, hasValue, useFilteredResults } from "src/utils";
import { API_SIP_GATEWAY, API_SMPP_GATEWAY } from "src/api/constants";
import {
API_ACCOUNTS,
API_SERVICE_PROVIDERS,
API_SIP_GATEWAY,
API_SMPP_GATEWAY,
USER_ACCOUNT,
} from "src/api/constants";
import { DeleteCarrier } from "./delete";
import type { Account, Carrier, SipGateway, SmppGateway } from "src/api/types";
import { Gateways } from "./gateways";
import { Scope } from "src/store/types";
export const Carriers = () => {
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [carrier, setCarrier] = useState<Carrier | null>(null);
const [carriers, refetch] = useServiceProviderData<Carrier[]>("VoipCarriers");
const [carriers, setCarriers] = useState<Carrier[]>();
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
const [filter, setFilter] = useState("");
@@ -39,15 +49,30 @@ export const Carriers = () => {
: carrier.account_sid === null
)
: [];
}, [accountSid, carriers]);
}, [accountSid, carrier, carriers]);
const filteredCarriers = useFilteredResults<Carrier>(
filter,
carriersFiltered
);
const getCarriers = (url: string) => {
getFetch<Carrier[]>(url)
.then(({ json }) => {
setCarriers(json);
})
.catch((error) => {
toastError(error.msg);
});
};
const handleDelete = () => {
if (carrier) {
if (user?.scope === USER_ACCOUNT && user.account_sid !== accountSid) {
toastError("You do not have permissions to delete this Carrier");
return;
}
deleteCarrier(carrier.voip_carrier_sid)
.then(() => {
Promise.all([
@@ -77,7 +102,15 @@ export const Carriers = () => {
)
);
});
refetch();
if ((user && user?.scope === USER_ACCOUNT) || accountSid) {
getCarriers(
`${API_ACCOUNTS}/${user?.account_sid || accountSid}/VoipCarriers`
);
} else {
getCarriers(
`${API_SERVICE_PROVIDERS}/${currentServiceProvider?.service_provider_sid}/VoipCarriers`
);
}
setCarrier(null);
toastSuccess(
<>
@@ -91,6 +124,20 @@ export const Carriers = () => {
}
};
useEffect(() => {
if (accountSid) {
getCarriers(
`${API_ACCOUNTS}/${user?.account_sid || accountSid}/VoipCarriers`
);
} else {
if (currentServiceProvider) {
getCarriers(
`${API_SERVICE_PROVIDERS}/${currentServiceProvider.service_provider_sid}/VoipCarriers`
);
}
}
}, [user, accountSid, currentServiceProvider]);
return (
<>
<section className="mast">
@@ -109,7 +156,13 @@ export const Carriers = () => {
/>
<AccountFilter
account={[accountSid, setAccountSid]}
accounts={accounts}
accounts={
user?.scope === USER_ACCOUNT
? accounts?.filter(
(acct) => acct.account_sid === user.account_sid
)
: accounts
}
label="Used by"
defaultOption
/>
@@ -123,14 +176,19 @@ export const Carriers = () => {
<div className="item" key={carrier.voip_carrier_sid}>
<div className="item__info">
<div className="item__title">
<Link
to={`${ROUTE_INTERNAL_CARRIERS}/${carrier.voip_carrier_sid}/edit`}
title="Edit Carrier"
className="i"
>
<ScopedAccess user={user} scope={Scope.service_provider}>
<Link
to={`${ROUTE_INTERNAL_CARRIERS}/${carrier.voip_carrier_sid}/edit`}
title="Edit Carrier"
className="i"
>
<strong>{carrier.name}</strong>
<Icons.ArrowRight />
</Link>
</ScopedAccess>
{user?.scope === USER_ACCOUNT && (
<strong>{carrier.name}</strong>
<Icons.ArrowRight />
</Link>
)}
</div>
<div className="item__meta">
<div>
@@ -150,22 +208,27 @@ export const Carriers = () => {
<Gateways carrier={carrier} />
</div>
</div>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_CARRIERS}/${carrier.voip_carrier_sid}/edit`}
title="Edit Carrier"
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete Carrier"
onClick={() => setCarrier(carrier)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
<ScopedAccess
user={user}
scope={!accountSid ? Scope.service_provider : Scope.account}
>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_CARRIERS}/${carrier.voip_carrier_sid}/edit`}
title="Edit Carrier"
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete Carrier"
onClick={() => setCarrier(carrier)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
</ScopedAccess>
</div>
))
) : (

View File

@@ -88,6 +88,7 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
.then(() => {
phoneNumber.refetch();
toastSuccess("Phone number updated successfully");
navigate(ROUTE_INTERNAL_PHONE_NUMBERS);
})
.catch((error) => {
toastError(error.msg);

View File

@@ -7,7 +7,7 @@ import {
putPhoneNumber,
useServiceProviderData,
} from "src/api";
import { toastError, toastSuccess } from "src/store";
import { toastError, toastSuccess, useSelectState } from "src/store";
import {
Icons,
Section,
@@ -30,8 +30,10 @@ import {
import { DeletePhoneNumber } from "./delete";
import type { Account, PhoneNumber, Carrier, Application } from "src/api/types";
import { USER_ACCOUNT } from "src/api/constants";
export const PhoneNumbers = () => {
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useServiceProviderData<Application[]>("Applications");
const [carriers] = useServiceProviderData<Carrier[]>("VoipCarriers");
@@ -123,8 +125,14 @@ export const PhoneNumbers = () => {
/>
<AccountFilter
account={[accountSid, setAccountSid]}
accounts={accounts}
defaultOption
accounts={
user?.scope === USER_ACCOUNT
? accounts?.filter(
(acct) => acct.account_sid === user.account_sid
)
: accounts
}
defaultOption={user?.scope !== USER_ACCOUNT}
/>
</section>
<Section {...(hasLength(filteredPhoneNumbers) && { slim: true })}>

View File

@@ -3,8 +3,12 @@ import { ButtonGroup, H1, M, MS } from "jambonz-ui";
import dayjs from "dayjs";
import { getRecentCalls, useServiceProviderData } from "src/api";
import { DATE_SELECTION, PER_PAGE_SELECTION } from "src/api/constants";
import { toastError } from "src/store";
import {
DATE_SELECTION,
PER_PAGE_SELECTION,
USER_ACCOUNT,
} from "src/api/constants";
import { toastError, useSelectState } from "src/store";
import {
Section,
AccountFilter,
@@ -30,6 +34,7 @@ const statusSelection = [
];
export const RecentCalls = () => {
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
const [dateFilter, setDateFilter] = useState("today");
@@ -86,7 +91,13 @@ export const RecentCalls = () => {
<section className="filters filters--multi">
<AccountFilter
account={[accountSid, setAccountSid]}
accounts={accounts}
accounts={
user?.scope === USER_ACCOUNT
? accounts?.filter(
(acct) => acct.account_sid === user.account_sid
)
: accounts
}
/>
<SelectFilter
id="date_filter"
@@ -114,7 +125,7 @@ export const RecentCalls = () => {
) : hasLength(calls) ? (
calls.map((call) => <DetailsItem key={call.call_sid} call={call} />)
) : (
<M>No data</M>
<M>No data.</M>
)}
</div>
</Section>

View File

@@ -5,15 +5,21 @@ import { withSelectState } from "src/utils";
import { ApiKeys } from "src/containers/internal/api-keys";
import ServiceProviderSettings from "./service-provider-settings";
import AdminSettings from "./admin-settings";
import { ScopedAccess } from "src/components/scoped-access";
import type { ServiceProvider } from "src/api/types";
import { Section } from "src/components";
import { USER_ADMIN } from "src/api/constants";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import { useSelectState } from "src/store";
import { Scope } from "src/store/types";
type SettingsProps = {
currentServiceProvider: ServiceProvider;
};
export const Settings = ({ currentServiceProvider }: SettingsProps) => {
const user = useSelectState("user");
const [activeTab, setActiveTab] = useState("");
return (
@@ -24,14 +30,21 @@ export const Settings = ({ currentServiceProvider }: SettingsProps) => {
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
</fieldset>
<Tabs active={[activeTab, setActiveTab]}>
<Tab id="admin" label="Admin">
<AdminSettings />
</Tab>
<Tab id="serviceProvider" label="Service Provider">
<ScopedAccess scope={Scope.admin} user={user}>
<Tabs active={[activeTab, setActiveTab]}>
<Tab id="admin" label="Admin">
<AdminSettings />
</Tab>
<Tab id="serviceProvider" label="Service Provider">
<ServiceProviderSettings />
</Tab>
</Tabs>
</ScopedAccess>
{user?.scope !== USER_ADMIN && (
<ScopedAccess scope={Scope.service_provider} user={user}>
<ServiceProviderSettings />
</Tab>
</Tabs>
</ScopedAccess>
)}
</form>
</Section>

View File

@@ -2,17 +2,25 @@ import React, { useEffect } from "react";
import { H1 } from "jambonz-ui";
import { useParams } from "react-router-dom";
import { useServiceProviderData } from "src/api";
import { toastError } from "src/store";
import { useApiData, useServiceProviderData } from "src/api";
import { toastError, useSelectState } from "src/store";
import { SpeechServiceForm } from "./form";
import type { SpeechCredential } from "src/api/types";
import { USER_ACCOUNT } from "src/api/constants";
export const EditSpeechService = () => {
const params = useParams();
const [data, refetch, error] = useServiceProviderData<SpeechCredential>(
`SpeechCredentials/${params.speech_credential_sid}`
);
const user = useSelectState("user");
const [data, refetch, error] =
user && user.scope !== USER_ACCOUNT
? useServiceProviderData<SpeechCredential>(
`SpeechCredentials/${params.speech_credential_sid}`
)
: useApiData<SpeechCredential>(
`Accounts/${user?.account_sid}/SpeechCredentials/${params.speech_credential_sid}`
);
useEffect(() => {
if (error) {

View File

@@ -34,6 +34,7 @@ import { CredentialStatus } from "./status";
import type { RegionVendors, GoogleServiceKey, Vendor } from "src/vendor/types";
import type { Account, SpeechCredential, UseApiDataMap } from "src/api/types";
import { USER_ACCOUNT } from "src/api/constants";
type SpeechServiceFormProps = {
credential?: UseApiDataMap<SpeechCredential>;
@@ -41,6 +42,7 @@ type SpeechServiceFormProps = {
export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const navigate = useNavigate();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const regions = useRegionVendors();
const [accounts] = useServiceProviderData<Account[]>("Accounts");
@@ -98,6 +100,13 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (user?.scope === USER_ACCOUNT && user.account_sid !== accountSid) {
toastError(
"You do not have permissions to make changes to these Speech Credentials"
);
return;
}
if (currentServiceProvider) {
const payload: Partial<SpeechCredential> = {
vendor,
@@ -124,10 +133,11 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
}),
};
if (credential && credential.data) {
if (credential && credential.data && user) {
/** The backend API returns obscured secrets now so we need to make sure we don't send them back */
/** Fields not sent back via :PUT are `service_key`, `access_key_id`, `secret_access_key` and `api_key` */
putSpeechService(
user,
currentServiceProvider.service_provider_sid,
credential.data.speech_credential_sid,
payload
@@ -136,42 +146,61 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
if (credential && credential.data) {
toastSuccess("Speech credential updated successfully");
credential.refetch();
navigate(ROUTE_INTERNAL_SPEECH);
}
})
.catch((error) => {
toastError(error.msg);
});
} else {
postSpeechService(currentServiceProvider.service_provider_sid, {
...payload,
service_key:
vendor === VENDOR_GOOGLE ? JSON.stringify(googleServiceKey) : null,
access_key_id: vendor === VENDOR_AWS ? accessKeyId : null,
secret_access_key: vendor === VENDOR_AWS ? secretAccessKey : null,
api_key:
vendor === VENDOR_MICROSOFT ||
vendor === VENDOR_WELLSAID ||
vendor === VENDOR_DEEPGRAM
? apiKey
: null,
client_id: vendor === VENDOR_NUANCE ? clientId : null,
secret: vendor === VENDOR_NUANCE ? secretKey : null,
stt_api_key: sttApiKey || null,
stt_region: sttRegion || null,
tts_api_key: ttsApiKey || null,
tts_region: ttsRegion || null,
})
.then(({ json }) => {
toastSuccess("Speech credential created successfully");
navigate(`${ROUTE_INTERNAL_SPEECH}/${json.sid}/edit`);
if (user)
postSpeechService(user, currentServiceProvider.service_provider_sid, {
...payload,
service_key:
vendor === VENDOR_GOOGLE
? JSON.stringify(googleServiceKey)
: null,
access_key_id: vendor === VENDOR_AWS ? accessKeyId : null,
secret_access_key: vendor === VENDOR_AWS ? secretAccessKey : null,
api_key:
vendor === VENDOR_MICROSOFT ||
vendor === VENDOR_WELLSAID ||
vendor === VENDOR_DEEPGRAM
? apiKey
: null,
client_id: vendor === VENDOR_NUANCE ? clientId : null,
secret: vendor === VENDOR_NUANCE ? secretKey : null,
stt_api_key: sttApiKey || null,
stt_region: sttRegion || null,
tts_api_key: ttsApiKey || null,
tts_region: ttsRegion || null,
})
.catch((error) => {
toastError(error.msg);
});
.then(({ json }) => {
toastSuccess("Speech credential created successfully");
navigate(`${ROUTE_INTERNAL_SPEECH}/${json.sid}/edit`);
})
.catch((error) => {
toastError(error.msg);
});
}
}
};
const checkOptions = () => {
if (user?.scope === USER_ACCOUNT) {
if (credential && credential.data?.account_sid) {
return false;
}
if (!credential) {
return false;
}
if (credential && !credential.data?.account_sid) {
return true;
}
}
return true;
};
useEffect(() => {
if (credential && credential.data) {
if (credential.data.vendor) {
@@ -289,10 +318,16 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
</fieldset>
<fieldset>
<AccountSelect
accounts={accounts}
accounts={
user?.scope === USER_ACCOUNT
? accounts?.filter(
(acct) => acct.account_sid === user.account_sid
)
: accounts
}
account={[accountSid, setAccountSid]}
required={false}
defaultOption
defaultOption={checkOptions()}
disabled={credential ? true : false}
/>
</fieldset>
@@ -541,7 +576,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
}}
checked={useCustomTts}
/>
<div>Use for custom text-to-speech</div>
<div>Use for custom voice</div>
</label>
<label htmlFor="use_custom_tts">
Custom voice endpoint{useCustomTts && <span>*</span>}
@@ -577,7 +612,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
}}
checked={useCustomStt}
/>
<div>Use for custom speech-to-text</div>
<div>Use for custom speech model</div>
</label>
<label htmlFor="use_custom_stt">
Custom speech endpoint id{useCustomStt && <span>*</span>}

View File

@@ -1,25 +1,53 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { Button, H1, Icon, M } from "jambonz-ui";
import { Link } from "react-router-dom";
import { API_ACCOUNTS, API_SERVICE_PROVIDERS } from "src/api/constants";
import {
API_ACCOUNTS,
API_SERVICE_PROVIDERS,
USER_ACCOUNT,
} from "src/api/constants";
import { AccountFilter, Icons, Section, Spinner } from "src/components";
import { useSelectState, toastError, toastSuccess } from "src/store";
import { getFetch, deleteSpeechService, useServiceProviderData } from "src/api";
import { ROUTE_INTERNAL_SPEECH } from "src/router/routes";
import { getHumanDateTime, hasLength, hasValue } from "src/utils";
import {
getHumanDateTime,
hasLength,
hasValue,
useFilteredResults,
} from "src/utils";
import DeleteSpeechService from "./delete";
import { getUsage } from "./utils";
import { CredentialStatus } from "./status";
import type { SpeechCredential, Account } from "src/api/types";
import { ScopedAccess } from "src/components/scoped-access";
import { Scope } from "src/store/types";
export const SpeechServices = () => {
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
const [credential, setCredential] = useState<SpeechCredential | null>(null);
const [credentials, setCredentials] = useState<SpeechCredential[]>();
const [filter] = useState("");
const credentialsFiltered = useMemo(() => {
return credentials
? credentials.filter((credentials) =>
accountSid
? credentials.account_sid === accountSid
: credentials.account_sid === null
)
: [];
}, [accountSid, accounts, credentials]);
const filteredCredentials = useFilteredResults<SpeechCredential>(
filter,
credentialsFiltered
);
const getSpeechCredentials = (url: string) => {
getFetch<SpeechCredential[]>(url)
@@ -33,14 +61,22 @@ export const SpeechServices = () => {
const handleDelete = () => {
if (credential && currentServiceProvider) {
if (user?.scope === USER_ACCOUNT && user.account_sid !== accountSid) {
toastError(
"You do not have permissions to delete these Speech Credentials"
);
return;
}
deleteSpeechService(
currentServiceProvider.service_provider_sid,
credential.speech_credential_sid
)
.then(() => {
if (accountSid) {
if ((user && user?.scope === USER_ACCOUNT) || accountSid) {
getSpeechCredentials(
`${API_ACCOUNTS}/${accountSid}/SpeechCredentials`
`${API_ACCOUNTS}/${
user?.account_sid || accountSid
}/SpeechCredentials`
);
} else {
getSpeechCredentials(
@@ -62,13 +98,17 @@ export const SpeechServices = () => {
useEffect(() => {
if (accountSid) {
getSpeechCredentials(`${API_ACCOUNTS}/${accountSid}/SpeechCredentials`);
} else if (currentServiceProvider) {
getSpeechCredentials(
`${API_SERVICE_PROVIDERS}/${currentServiceProvider.service_provider_sid}/SpeechCredentials`
`${API_ACCOUNTS}/${user?.account_sid || accountSid}/SpeechCredentials`
);
} else {
if (currentServiceProvider) {
getSpeechCredentials(
`${API_SERVICE_PROVIDERS}/${currentServiceProvider.service_provider_sid}/SpeechCredentials`
);
}
}
}, [accountSid, currentServiceProvider]);
}, [user, accountSid, currentServiceProvider]);
return (
<>
@@ -83,29 +123,45 @@ export const SpeechServices = () => {
<section className="filters filters--ender">
<AccountFilter
account={[accountSid, setAccountSid]}
accounts={accounts}
accounts={
user?.scope === USER_ACCOUNT
? accounts?.filter(
(acct) => acct.account_sid === user.account_sid
)
: accounts
}
label="Used by"
defaultOption
/>
</section>
<Section {...(hasLength(credentials) && { slim: true })}>
<Section {...(hasLength(filteredCredentials) && { slim: true })}>
<div className="list">
{!hasValue(credentials) ? (
{!hasValue(filteredCredentials) ? (
<Spinner />
) : hasLength(credentials) ? (
credentials.map((credential) => {
) : hasLength(filteredCredentials) ? (
filteredCredentials.map((credential) => {
return (
<div className="item" key={credential.speech_credential_sid}>
<div className="item__info">
<div className="item__title">
<Link
to={`${ROUTE_INTERNAL_SPEECH}/${credential.speech_credential_sid}/edit`}
title="Edit application"
className="i"
<ScopedAccess
user={user}
scope={
!accountSid ? Scope.service_provider : Scope.account
}
>
<Link
to={`${ROUTE_INTERNAL_SPEECH}/${credential.speech_credential_sid}/edit`}
title="Edit application"
className="i"
>
<strong>Vendor: {credential.vendor}</strong>
<Icons.ArrowRight />
</Link>
</ScopedAccess>
{user?.scope === USER_ACCOUNT && (
<strong>Vendor: {credential.vendor}</strong>
<Icons.ArrowRight />
</Link>
)}
</div>
<div className="item__meta">
<div>
@@ -147,22 +203,27 @@ export const SpeechServices = () => {
</div>
</div>
</div>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_SPEECH}/${credential.speech_credential_sid}/edit`}
title="Edit speech service"
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete speech service"
onClick={() => setCredential(credential)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
<ScopedAccess
user={user}
scope={!accountSid ? Scope.service_provider : Scope.account}
>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_SPEECH}/${credential.speech_credential_sid}/edit`}
title="Edit speech service"
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete speech service"
onClick={() => setCredential(credential)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
</ScopedAccess>
</div>
);
})

View File

@@ -1,12 +1,13 @@
import React, { useState } from "react";
import { MS } from "jambonz-ui";
import { CRED_NOT_TESTED, CRED_OK } from "src/api/constants";
import { CRED_NOT_TESTED, CRED_OK, USER_ACCOUNT } from "src/api/constants";
import { Icons, Spinner } from "src/components";
import { useServiceProviderData } from "src/api";
import { useApiData, useServiceProviderData } from "src/api";
import { getStatus, getReason } from "./utils";
import type { SpeechCredential, CredentialTestResult } from "src/api/types";
import { useSelectState } from "src/store";
type CredentialStatusProps = {
cred: SpeechCredential;
@@ -17,10 +18,15 @@ export const CredentialStatus = ({
cred,
showSummary = false,
}: CredentialStatusProps) => {
const user = useSelectState("user");
const [testResult, testRefetch, testError] =
useServiceProviderData<CredentialTestResult>(
`SpeechCredentials/${cred.speech_credential_sid}/test`
);
user && user.scope !== USER_ACCOUNT
? useServiceProviderData<CredentialTestResult>(
`SpeechCredentials/${cred.speech_credential_sid}/test`
)
: useApiData<CredentialTestResult>(
`Accounts/${user?.account_sid}/SpeechCredentials/${cred.speech_credential_sid}/test`
);
const notTestedTxt =
"In order to test your credentials you need to enable TTS/STT.";

View File

@@ -52,7 +52,9 @@ export const UserForm = ({ user }: UserFormProps) => {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [initialPassword, setInitialPassword] = useState("");
const [scope, setScope] = useState<UserScopes>();
const [scope, setScope] = useState<UserScopes | null>(
currentUser?.scope || null
);
const [isActive, setIsActive] = useState(true);
const [forceChange, setForceChange] = useState(true);
const [modal, setModal] = useState(false);
@@ -168,6 +170,7 @@ export const UserForm = ({ user }: UserFormProps) => {
setIsActive(!!user.data.is_active);
setEmail(user.data.email);
setScope(getUserScope(user.data));
user.data.account_sid ? setAccountSid(user.data.account_sid) : false;
}
}, [user]);
@@ -184,7 +187,7 @@ export const UserForm = ({ user }: UserFormProps) => {
<Selector
id="scope"
name="scope"
value={scope}
value={scope || currentUser?.scope}
options={
currentUser?.scope === USER_SP
? USER_SCOPE_SELECTION.filter(
@@ -203,7 +206,7 @@ export const UserForm = ({ user }: UserFormProps) => {
/>
</>
)}
{scope !== USER_ACCOUNT && !hasLength(accounts) && (
{scope === USER_ACCOUNT && !hasLength(accounts) && (
<>
<label htmlFor="account">
Account:<span>*</span>

View File

@@ -4,7 +4,11 @@ import { Link } from "react-router-dom";
import { useApiData, useServiceProviderData } from "src/api";
import { ROUTE_INTERNAL_USERS } from "src/router/routes";
import { USER_SCOPE_SELECTION, USER_ADMIN } from "src/api/constants";
import {
USER_SCOPE_SELECTION,
USER_ADMIN,
USER_ACCOUNT,
} from "src/api/constants";
import {
Section,
@@ -14,12 +18,18 @@ import {
AccountFilter,
SelectFilter,
} from "src/components";
import { hasLength, hasValue, useFilteredResults } from "src/utils";
import {
hasLength,
hasValue,
sortAlphabetically,
useFilteredResults,
} from "src/utils";
import type { Account, User } from "src/api/types";
import { useSelectState } from "src/store";
export const Users = () => {
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [users] = useApiData<User[]>("Users");
const [filter, setFilter] = useState("");
@@ -28,6 +38,15 @@ export const Users = () => {
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const usersFiltered = useMemo(() => {
//find and add account/sp names to user to improve fuzzy search
users?.forEach((user) => {
user.account_name =
accounts?.find((acct) => acct.account_sid === user.account_sid)?.name ||
null;
user.service_provider_name =
user.scope === USER_ADMIN ? null : currentServiceProvider?.name || null;
});
const serviceProviderUsers = users?.filter((e) => {
return (
e.scope === USER_ADMIN ||
@@ -55,7 +74,9 @@ export const Users = () => {
return [];
}, [accountSid, scopeFilter, users, accounts, currentServiceProvider]);
const filteredUsers = useFilteredResults<User>(filter, usersFiltered);
const filteredUsers = useFilteredResults<User>(filter, usersFiltered)?.sort(
(a, b) => sortAlphabetically(a, b)
);
return (
<>
@@ -82,16 +103,21 @@ export const Users = () => {
/>
<AccountFilter
account={[accountSid, setAccountSid]}
accounts={accounts}
defaultOption={true}
accounts={
user?.scope === USER_ACCOUNT
? accounts?.filter(
(acct) => acct.account_sid === user.account_sid
)
: accounts
}
defaultOption={user?.scope !== USER_ACCOUNT}
/>
</section>
<Section slim>
<div className="grid grid--col4">
<div className="grid grid--col3--users">
<div className="grid__row grid__th">
<div>User Name</div>
<div>Email</div>
<div>Scope</div>
<div>&nbsp;</div>
</div>
@@ -101,9 +127,21 @@ export const Users = () => {
filteredUsers.map((user) => {
return (
<div className="grid__row" key={user.user_sid}>
<div>{user.name}</div>
<div>{user.email}</div>
<div>{user.scope}</div>
<div>
<Link
to={`${ROUTE_INTERNAL_USERS}/${user.user_sid}/edit`}
title="Edit user"
>
<div>{user.name}</div>
</Link>
</div>
<div>
{user.scope === USER_ADMIN
? "All"
: user.account_name
? `Account: ${user.account_name}`
: `Service Provider: ${user.service_provider_name}`}
</div>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_USERS}/${user.user_sid}/edit`}

View File

@@ -6,7 +6,11 @@ import { isValidPasswd } from "src/utils";
import { putUser, useApiData } from "src/api";
import { PasswordSettings, StatusCodes } from "src/api/types";
import { Passwd, Message } from "src/components/forms";
import { ROUTE_LOGIN, ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import {
ROUTE_LOGIN,
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_APPLICATIONS,
} from "src/router/routes";
import {
SESS_OLD_PASSWORD,
SESS_USER_SID,
@@ -17,6 +21,8 @@ import {
} from "src/constants";
import type { IMessage } from "src/store/types";
import { USER_ACCOUNT } from "src/api/constants";
import { getToken, parseJwt } from "src/router/auth";
export const CreatePassword = () => {
const [passwdSettings] = useApiData<PasswordSettings>("PasswordSettings");
@@ -50,6 +56,7 @@ export const CreatePassword = () => {
const userSid = sessionStorage.getItem(SESS_USER_SID);
const oldPassword = sessionStorage.getItem(SESS_OLD_PASSWORD);
const token = getToken();
if (!oldPassword) {
navigate(ROUTE_LOGIN);
@@ -65,7 +72,11 @@ export const CreatePassword = () => {
if (response.status === StatusCodes.NO_CONTENT) {
sessionStorage.clear();
navigate(ROUTE_INTERNAL_ACCOUNTS);
navigate(
parseJwt(token).scope !== USER_ACCOUNT
? ROUTE_INTERNAL_ACCOUNTS
: ROUTE_INTERNAL_APPLICATIONS
);
} else {
setMessage(MSG_SOMETHING_WRONG);
}

View File

@@ -2,18 +2,21 @@ import React, { useEffect, useState } from "react";
import { Button, H1 } from "jambonz-ui";
import { useLocation, Navigate } from "react-router-dom";
import { toastError } from "src/store";
import { toastError, useSelectState } from "src/store";
import { useAuth } from "src/router/auth";
import { SESS_UNAUTHORIZED, SESS_OLD_PASSWORD } from "src/constants";
import { Passwd, Message } from "src/components/forms";
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_CREATE_PASSWORD,
ROUTE_INTERNAL_APPLICATIONS,
} from "src/router/routes";
import { USER_ACCOUNT } from "src/api/constants";
export const Login = () => {
const { signin, authorized } = useAuth();
const location = useLocation();
const user = useSelectState("user");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [message, setMessage] = useState("");
@@ -49,7 +52,11 @@ export const Login = () => {
return (
<Navigate
to={ROUTE_INTERNAL_ACCOUNTS}
to={
user?.scope !== USER_ACCOUNT
? ROUTE_INTERNAL_ACCOUNTS
: ROUTE_INTERNAL_APPLICATIONS
}
state={{ from: location }}
replace
/>

View File

@@ -10,6 +10,7 @@ import {
ROUTE_LOGIN,
ROUTE_CREATE_PASSWORD,
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_APPLICATIONS,
} from "./routes";
import {
SESS_OLD_PASSWORD,
@@ -20,6 +21,7 @@ import {
} from "src/constants";
import type { UserLogin } from "src/api/types";
import { USER_ACCOUNT } from "src/api/constants";
interface SignIn {
(username: string, password: string): Promise<UserLogin>;
@@ -101,7 +103,11 @@ export const useProvideAuth = (): AuthStateContext => {
sessionStorage.setItem(SESS_OLD_PASSWORD, password);
navigate(ROUTE_CREATE_PASSWORD);
} else {
navigate(ROUTE_INTERNAL_ACCOUNTS);
navigate(
parseJwt(token).scope !== USER_ACCOUNT
? ROUTE_INTERNAL_ACCOUNTS
: ROUTE_INTERNAL_APPLICATIONS
);
}
resolve(response.json);

View File

@@ -99,17 +99,16 @@
}
}
&--col4 {
&--col3--users {
.grid__row {
grid-template-columns: [col] 30% [col] 30% [col] 30% [col] 10%;
grid-template-columns: [col] 40% [col] 58% [col] 2%;
grid-template-rows: [row] auto [row] auto [row] [row] auto;
display: grid;
justify-content: space-between;
> div:last-child {
text-align: right;
text-align: left;
padding-right: 0;
font-size: 0;
}
}
}

View File

@@ -34,6 +34,7 @@
display: flex;
align-items: center;
grid-gap: ui-vars.$px02;
color: ui-vars.$jambonz;
+ .item__meta {
@include mixins.small() {

View File

@@ -22,7 +22,6 @@
@mixin icosize {
height: ui-vars.$h3-size;
width: ui-vars.$h3-size;
margin-right: auto;
cursor: pointer;
svg {

View File

@@ -4,6 +4,7 @@ import { withAccessControl } from "./with-access-control";
import { withSelectState } from "./with-select-state";
import { useRedirect } from "./use-redirect";
import { useFilteredResults } from "./use-filtered-results";
import { useScopedRedirect } from "./use-scoped-redirect";
import {
FQDN,
FQDN_TOP_LEVEL,
@@ -148,6 +149,20 @@ export const getUserScope = (user: User): UserScopes => {
}
};
export const sortAlphabetically = (a: User, b: User) => {
const nameA = a.name.toUpperCase(); // ignore upper and lowercase
const nameB = b.name.toUpperCase(); // ignore upper and lowercase
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
// names must be equal
return 0;
};
export {
withSuspense,
useMobileMedia,
@@ -155,4 +170,5 @@ export {
withSelectState,
useRedirect,
useFilteredResults,
useScopedRedirect,
};

View File

@@ -0,0 +1,23 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toastError } from "src/store";
import type { IMessage, Scope, UserData } from "src/store/types";
export const useScopedRedirect = (
access: Scope,
redirect: string,
user?: UserData,
message?: IMessage
) => {
const navigate = useNavigate();
useEffect(() => {
if (user && access >= user.access) {
if (message) toastError(message);
navigate(redirect);
}
}, [user]);
};