diff --git a/src/api/constants.ts b/src/api/constants.ts index f962de5..450fba7 100644 --- a/src/api/constants.ts +++ b/src/api/constants.ts @@ -2,6 +2,7 @@ import type { LimitField, LimitUnitOption, PasswordSettings, + SelectorOptions, SipGateway, SmppGateway, WebHook, @@ -104,7 +105,7 @@ export const PER_PAGE_SELECTION = [ { name: "100 / page", value: "100" }, ]; -export const USER_SCOPE_SELECTION = [ +export const USER_SCOPE_SELECTION: SelectorOptions[] = [ { name: "All Scopes", value: "all" }, { name: "Admin", value: "admin" }, { name: "Service Provider", value: "service_provider" }, diff --git a/src/api/index.ts b/src/api/index.ts index 1cd2124..a3455cf 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useSelectState } from "src/store"; -import { getToken } from "src/router/auth"; +import { getToken, parseJwt } from "src/router/auth"; import { DEV_BASE_URL, API_BASE_URL, @@ -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 { @@ -260,10 +261,13 @@ export const postSpeechService = ( sid: string, payload: Partial ) => { - return postFetch>( - `${API_SERVICE_PROVIDERS}/${sid}/SpeechCredentials`, - payload - ); + const userData = parseJwt(getToken()); + const apiUrl = + userData.scope === USER_ACCOUNT + ? `${API_ACCOUNTS}/${userData.account_sid}/SpeechCredentials` + : `${API_SERVICE_PROVIDERS}/${sid}/SpeechCredentials`; + + return postFetch>(apiUrl, payload); }; export const postMsTeamsTentant = (payload: Partial) => { @@ -281,10 +285,13 @@ export const postPhoneNumber = (payload: Partial) => { }; export const postCarrier = (sid: string, payload: Partial) => { - return postFetch>( - `${API_SERVICE_PROVIDERS}/${sid}/VoipCarriers/`, - payload - ); + const userData = parseJwt(getToken()); + const apiUrl = + userData.scope === USER_ACCOUNT + ? `${API_ACCOUNTS}/${userData.account_sid}/VoipCarriers/` + : `${API_SERVICE_PROVIDERS}/${sid}/VoipCarriers/`; + + return postFetch>(apiUrl, payload); }; export const postPredefinedCarrierTemplate = ( @@ -296,6 +303,15 @@ export const postPredefinedCarrierTemplate = ( ); }; +export const postPredefinedCarrierTemplateAccount = ( + accountSid: string, + predefinedCarrierSid: string +) => { + return postFetch( + `${API_BASE_URL}/Accounts/${accountSid}/PredefinedCarriers/${predefinedCarrierSid}` + ); +}; + export const postSipGateway = (payload: Partial) => { return postFetch>(API_SIP_GATEWAY, payload); }; @@ -368,10 +384,13 @@ export const putSpeechService = ( sid2: string, payload: Partial ) => { - return putFetch>( - `${API_SERVICE_PROVIDERS}/${sid1}/SpeechCredentials/${sid2}`, - payload - ); + const userData = parseJwt(getToken()); + const apiUrl = + userData.scope === USER_ACCOUNT + ? `${API_ACCOUNTS}/${userData.account_sid}/SpeechCredentials/${sid2}` + : `${API_SERVICE_PROVIDERS}/${sid1}/SpeechCredentials/${sid2}`; + + return putFetch>(apiUrl, payload); }; export const putMsTeamsTenant = ( @@ -396,10 +415,13 @@ export const putCarrier = ( sid2: string, payload: Partial ) => { - return putFetch>( - `${API_SERVICE_PROVIDERS}/${sid1}/VoipCarriers/${sid2}`, - payload - ); + const userData = parseJwt(getToken()); + const apiUrl = + userData.scope === USER_ACCOUNT + ? `${API_ACCOUNTS}/${userData.account_sid}/VoipCarriers/${sid2}` + : `${API_SERVICE_PROVIDERS}/${sid1}/VoipCarriers/${sid2}`; + + return putFetch>(apiUrl, payload); }; export const putSipGateway = (sid: string, payload: Partial) => { @@ -548,22 +570,27 @@ export const useApiData: UseApiData = (apiPath: string) => { useEffect(() => { let ignore = false; - getFetch(`${API_BASE_URL}/${apiPath}`) - .then(({ json }) => { - if (!ignore) { - setResult(json!); - } - }) - .catch((error) => { - if (!ignore) { - setError(error); - } - }); + // Don't fetch if api url is empty string "" + if (apiPath) { + getFetch(`${API_BASE_URL}/${apiPath}`) + .then(({ json }) => { + if (!ignore) { + setResult(json!); + } + }) + .catch((error) => { + if (!ignore) { + setError(error); + } + }); + } return function cleanup() { ignore = true; }; - }, [refetch]); + + // Refetch data if refetcher() is called OR api url changes + }, [refetch, apiPath]); return [result, refetcher, error]; }; diff --git a/src/api/types.ts b/src/api/types.ts index 623ad1b..0fa144e 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -82,6 +82,11 @@ export interface WebhookOption { value: WebhookMethod; } +export interface SelectorOptions { + name: string; + value: string; +} + export interface Pcap { data_url: string; file_name: string; @@ -118,7 +123,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[]; } @@ -154,6 +161,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 { diff --git a/src/components/forms/local-limits.tsx b/src/components/forms/local-limits.tsx index 21cae23..5b14da3 100644 --- a/src/components/forms/local-limits.tsx +++ b/src/components/forms/local-limits.tsx @@ -53,6 +53,11 @@ export const LocalLimits = ({ useEffect(() => { if (hasLength(data)) { setLocalLimits(data); + setUnit(() => { + return data.find((l) => l.category.includes(LIMIT_MINS)) + ? LIMIT_MINS + : LIMIT_SESS; + }); } else { setLocalLimits([]); } diff --git a/src/components/icons.ts b/src/components/icons.ts index ab387d6..7f3e2f9 100644 --- a/src/components/icons.ts +++ b/src/components/icons.ts @@ -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, diff --git a/src/components/index.ts b/src/components/index.ts index 7e43f4b..9dd6bb5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -13,6 +13,7 @@ import { SelectFilter } from "./select-filter"; import { Pagination } from "./pagination"; import { ApplicationFilter } from "./application-filter"; import { SearchFilter } from "./search-filter"; +import { ScopedAccess } from "./scoped-access"; export { Icons, @@ -32,4 +33,5 @@ export { Pagination, ApplicationFilter, SearchFilter, + ScopedAccess, }; diff --git a/src/components/scoped-access.tsx b/src/components/scoped-access.tsx index 13406e1..77fbff7 100644 --- a/src/components/scoped-access.tsx +++ b/src/components/scoped-access.tsx @@ -3,8 +3,12 @@ import React from "react"; import type { UserData, Scope } from "src/store/types"; export type ScopedAccessProps = { - user?: UserData; + /** + * Minumum required scope + * @see enum `Scope` in src/store/types + */ scope: Scope; + user?: UserData; children: React.ReactNode; }; diff --git a/src/containers/internal/layout.tsx b/src/containers/internal/layout.tsx index e43dab3..2368dd3 100644 --- a/src/containers/internal/layout.tsx +++ b/src/containers/internal/layout.tsx @@ -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 = () => { + - + +
+ + + + +
+
)) ) : ( diff --git a/src/containers/internal/views/phone-numbers/form.tsx b/src/containers/internal/views/phone-numbers/form.tsx index e6d0228..6550b37 100644 --- a/src/containers/internal/views/phone-numbers/form.tsx +++ b/src/containers/internal/views/phone-numbers/form.tsx @@ -88,6 +88,9 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => { .then(() => { phoneNumber.refetch(); toastSuccess("Phone number updated successfully"); + navigate( + `${ROUTE_INTERNAL_PHONE_NUMBERS}/${phoneNumber.data?.phone_number_sid}/edit` + ); }) .catch((error) => { toastError(error.msg); @@ -98,9 +101,9 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => { number: phoneNumberNum, voip_carrier_sid: sipTrunkSid, }) - .then(({ json }) => { + .then(() => { toastSuccess("Phone number created successfully"); - navigate(`${ROUTE_INTERNAL_PHONE_NUMBERS}/${json.sid}/edit`); + navigate(ROUTE_INTERNAL_PHONE_NUMBERS); }) .catch((error) => { toastError(error.msg); diff --git a/src/containers/internal/views/phone-numbers/index.tsx b/src/containers/internal/views/phone-numbers/index.tsx index 8e09cab..0ca0609 100644 --- a/src/containers/internal/views/phone-numbers/index.tsx +++ b/src/containers/internal/views/phone-numbers/index.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Button, ButtonGroup, H1, Icon, MS } from "jambonz-ui"; import { Link } from "react-router-dom"; @@ -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,12 @@ import { import { DeletePhoneNumber } from "./delete"; import type { Account, PhoneNumber, Carrier, Application } from "src/api/types"; +import { USER_ACCOUNT } from "src/api/constants"; +import { ScopedAccess } from "src/components/scoped-access"; +import { Scope } from "src/store/types"; export const PhoneNumbers = () => { + const user = useSelectState("user"); const [accounts] = useServiceProviderData("Accounts"); const [applications] = useServiceProviderData("Applications"); const [carriers] = useServiceProviderData("VoipCarriers"); @@ -101,6 +105,12 @@ export const PhoneNumbers = () => { } }; + useEffect(() => { + if (user?.account_sid && user.scope === USER_ACCOUNT) { + setAccountSid(user?.account_sid); + } + }, [user]); + return ( <>
@@ -121,11 +131,13 @@ export const PhoneNumbers = () => { placeholder="Filter phone numbers" filter={[filter, setFilter]} /> - + + +
diff --git a/src/containers/internal/views/recent-calls/index.tsx b/src/containers/internal/views/recent-calls/index.tsx index fa4feba..1e38872 100644 --- a/src/containers/internal/views/recent-calls/index.tsx +++ b/src/containers/internal/views/recent-calls/index.tsx @@ -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, @@ -16,6 +20,8 @@ import { hasLength, hasValue } from "src/utils"; import { DetailsItem } from "./details"; import type { Account, CallQuery, RecentCall } from "src/api/types"; +import { ScopedAccess } from "src/components/scoped-access"; +import { Scope } from "src/store/types"; const directionSelection = [ { name: "either", value: "io" }, @@ -30,6 +36,7 @@ const statusSelection = [ ]; export const RecentCalls = () => { + const user = useSelectState("user"); const [accounts] = useServiceProviderData("Accounts"); const [accountSid, setAccountSid] = useState(""); const [dateFilter, setDateFilter] = useState("today"); @@ -74,8 +81,12 @@ export const RecentCalls = () => { /** Reset page number when filters change */ useEffect(() => { + if (user?.account_sid && user.scope === USER_ACCOUNT) { + setAccountSid(user?.account_sid); + } + setPageNumber(1); - }, [accountSid, dateFilter, directionFilter, statusFilter]); + }, [user, accountSid, dateFilter, directionFilter, statusFilter]); return ( <> @@ -84,10 +95,12 @@ export const RecentCalls = () => {
{/* Setting overflow-x auto for now until we have a better responsive solution... */}
- + + + { ) : hasLength(calls) ? ( calls.map((call) => ) ) : ( - No data + No data. )}
diff --git a/src/containers/internal/views/settings/index.tsx b/src/containers/internal/views/settings/index.tsx index 794147d..70aca99 100644 --- a/src/containers/internal/views/settings/index.tsx +++ b/src/containers/internal/views/settings/index.tsx @@ -1,21 +1,35 @@ import React, { useState } from "react"; import { H1, Tabs, Tab, MS } from "jambonz-ui"; -import { withSelectState } from "src/utils"; +import { useScopedRedirect, 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"; +import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes"; type SettingsProps = { currentServiceProvider: ServiceProvider; }; export const Settings = ({ currentServiceProvider }: SettingsProps) => { + const user = useSelectState("user"); const [activeTab, setActiveTab] = useState(""); + useScopedRedirect( + Scope.service_provider, + `${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/edit`, + user, + "You do not have permissions to manage Settings" + ); + return ( <>

Settings

@@ -24,14 +38,21 @@ export const Settings = ({ currentServiceProvider }: SettingsProps) => {
{MSG_REQUIRED_FIELDS}
- - - - - + + + + + + + + + + + {user?.scope !== USER_ADMIN && ( + - - + + )} diff --git a/src/containers/internal/views/speech-services/edit.tsx b/src/containers/internal/views/speech-services/edit.tsx index f71b968..a9b9558 100644 --- a/src/containers/internal/views/speech-services/edit.tsx +++ b/src/containers/internal/views/speech-services/edit.tsx @@ -1,24 +1,50 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } 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 } 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"; +import { useScopedRedirect } from "src/utils/use-scoped-redirect"; +import { Scope } from "src/store/types"; +import { ROUTE_INTERNAL_SPEECH } from "src/router/routes"; +import { useParams } from "react-router-dom"; export const EditSpeechService = () => { const params = useParams(); - const [data, refetch, error] = useServiceProviderData( - `SpeechCredentials/${params.speech_credential_sid}` + const user = useSelectState("user"); + const currentServiceProvider = useSelectState("currentServiceProvider"); + const [url, setUrl] = useState(""); + const [data, refetch, error] = useApiData(url); + + useScopedRedirect( + Scope.account, + ROUTE_INTERNAL_SPEECH, + user, + "You do not have access to this resource", + data ); + const getUrlForSpeech = () => { + if (user && user?.scope === USER_ACCOUNT) { + setUrl( + `Accounts/${user?.account_sid}/SpeechCredentials/${params.speech_credential_sid}` + ); + } else { + setUrl( + `ServiceProviders/${currentServiceProvider?.service_provider_sid}/SpeechCredentials/${params.speech_credential_sid}` + ); + } + }; + useEffect(() => { + getUrlForSpeech(); if (error) { toastError(error.msg); } - }, [error]); + }, [error, data, url]); return ( <> diff --git a/src/containers/internal/views/speech-services/form.tsx b/src/containers/internal/views/speech-services/form.tsx index e6c3573..05151b5 100644 --- a/src/containers/internal/views/speech-services/form.tsx +++ b/src/containers/internal/views/speech-services/form.tsx @@ -28,7 +28,11 @@ import { VENDOR_IBM, } from "src/vendor"; import { MSG_REQUIRED_FIELDS } from "src/constants"; -import { getObscuredSecret } from "src/utils"; +import { + checkSelectOptions, + getObscuredSecret, + isUserAccountScope, +} from "src/utils"; import { getObscuredGoogleServiceKey } from "./utils"; import { CredentialStatus } from "./status"; @@ -41,6 +45,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("Accounts"); @@ -98,6 +103,13 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + if (isUserAccountScope(accountSid, user)) { + toastError( + "You do not have permissions to make changes to these Speech Credentials" + ); + return; + } + if (currentServiceProvider) { const payload: Partial = { vendor, @@ -136,6 +148,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => { if (credential && credential.data) { toastSuccess("Speech credential updated successfully"); credential.refetch(); + navigate( + `${ROUTE_INTERNAL_SPEECH}/${credential.data.speech_credential_sid}/edit` + ); } }) .catch((error) => { @@ -156,14 +171,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => { : 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 }) => { + .then(() => { toastSuccess("Speech credential created successfully"); - navigate(`${ROUTE_INTERNAL_SPEECH}/${json.sid}/edit`); + navigate(ROUTE_INTERNAL_SPEECH); }) .catch((error) => { toastError(error.msg); @@ -292,7 +303,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => { accounts={accounts} account={[accountSid, setAccountSid]} required={false} - defaultOption + defaultOption={checkSelectOptions(user, credential?.data)} disabled={credential ? true : false} /> @@ -541,7 +552,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => { }} checked={useCustomTts} /> -
Use for custom text-to-speech
+
Use for custom voice