This commit is contained in:
Quan HL
2023-10-23 10:33:37 +07:00
parent 2134eaf732
commit e0687c2b53
10 changed files with 399 additions and 38 deletions

1
src/api/constants.ts Normal file
View File

@@ -0,0 +1 @@
export const MSG_SOMETHING_WRONG = "Something went wrong, please try again";

140
src/api/index.ts Normal file
View File

@@ -0,0 +1,140 @@
import { Application, FetchError, FetchTransport, StatusCodes } from "./types";
import { MSG_SOMETHING_WRONG } from "./constants";
import { getAdvancedSettings } from "src/storage";
const fetchTransport = <Type>(
url: string,
options: RequestInit
): Promise<FetchTransport<Type>> => {
return new Promise(async (resolve, reject) => {
try {
const response = await fetch(url, options);
const transport: FetchTransport<Type> = {
headers: response.headers,
status: response.status,
json: <Type>{},
};
// Redirect unauthorized
if (response.status === StatusCodes.UNAUTHORIZED) {
reject();
}
// API error handling returns { msg: string; }
// See @type StatusJSON and StatusEmpty in ./types
if (
response.status >= StatusCodes.BAD_REQUEST &&
response.status <= StatusCodes.INTERNAL_SERVER_ERROR
) {
try {
const errJson = await response.json();
reject(<FetchError>{
status: response.status,
...errJson,
});
} catch (error) {
reject(<FetchError>{
status: response.status,
msg: MSG_SOMETHING_WRONG,
});
}
}
// API success handling returns a valid JSON response
// This could either be a DTO object or a generic response
// See types for various responses in ./types
if (
response.status === StatusCodes.OK ||
response.status === StatusCodes.CREATED
) {
// Handle blobs -- e.g. pcap file API for RecentCalls
if (
options.headers!["Content-Type" as keyof HeadersInit] ===
"application/octet-stream"
) {
const blob: Blob = await response.blob();
transport.blob = blob;
} else {
const json: Type = await response.json();
transport.json = json;
}
}
resolve(transport);
// TypeError "Failed to fetch"
// net::ERR_CONNECTION_REFUSED
// This is the case if the server is unreachable...
} catch (error: unknown) {
reject(<FetchError>{
status: StatusCodes.INTERNAL_SERVER_ERROR,
msg: (error as TypeError).message,
});
}
});
};
const getAuthHeaders = () => {
const advancedSettings = getAdvancedSettings();
let token = advancedSettings.apiKey ?? null;
return {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
};
};
export const getFetch = <Type>(url: string) => {
return fetchTransport<Type>(url, {
headers: getAuthHeaders(),
});
};
export const postFetch = <Type, Payload = undefined>(
url: string,
payload?: Payload
) => {
return fetchTransport<Type>(url, {
method: "POST",
...(payload && { body: JSON.stringify(payload) }),
headers: getAuthHeaders(),
});
};
export const putFetch = <Type, Payload>(url: string, payload: Payload) => {
return fetchTransport<Type>(url, {
method: "PUT",
body: JSON.stringify(payload),
headers: getAuthHeaders(),
});
};
export const deleteFetch = <Type>(url: string) => {
return fetchTransport<Type>(url, {
method: "DELETE",
headers: getAuthHeaders(),
});
};
// GET Devices Users
export const getRegisteredUser = () => {
const advancedSettings = getAdvancedSettings();
return getFetch<string[]>(
`${advancedSettings.apiServer}/Accounts/${advancedSettings.accountSid}/RegisteredSipUsers`
);
};
export const getApplications = () => {
const advancedSettings = getAdvancedSettings();
return getFetch<Application[]>(
`${advancedSettings.apiServer}/Accounts/${advancedSettings.accountSid}/Applications`
);
};
export const getQueues = () => {
const advancedSettings = getAdvancedSettings();
return getFetch<string[]>(
`${advancedSettings.apiServer}/Accounts/${advancedSettings.accountSid}/Queues`
);
};

31
src/api/types.ts Normal file
View File

@@ -0,0 +1,31 @@
export enum StatusCodes {
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NO_CONTENT = 204,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
UNPROCESSABLE_ENTITY = 422,
INTERNAL_SERVER_ERROR = 500,
/** SMPP temporarily unavailable */
TEMPORARILY_UNAVAILABLE = 480,
}
export interface FetchTransport<Type> {
headers: Headers;
status: StatusCodes;
json: Type;
blob?: Blob;
}
export interface FetchError {
status: StatusCodes;
msg: string;
}
export interface Application {
application_sid: string;
name: string;
}

View File

@@ -58,7 +58,8 @@ export interface AppSettings {
}
export interface AdvancedAppSettings {
apikey: string;
accountSid: string;
apiKey: string;
apiServer: string;
}

View File

@@ -0,0 +1,60 @@
import {
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Spinner,
} from "@chakra-ui/react";
import { useState } from "react";
export interface IconButtonMenuItems {
name: string;
value: string;
}
type IconButtonMenuProbs = {
icon: React.ReactElement;
onOpen: () => Promise<IconButtonMenuItems[]>;
onClick: (value: string) => void;
};
export const IconButtonMenu = ({
icon,
onOpen,
onClick,
}: IconButtonMenuProbs) => {
const [items, setItems] = useState<IconButtonMenuItems[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleOnOpen = () => {
setIsLoading(true);
onOpen()
.then((values) => setItems(values))
.finally(() => setIsLoading(false));
};
return (
<Menu onOpen={handleOnOpen}>
<MenuButton
as={IconButton}
aria-label="Options"
icon={icon}
variant="unstyled"
/>
<MenuList>
{isLoading ? (
<MenuItem>
<Spinner color="jambonz.500" size="xs" />
</MenuItem>
) : (
items.map((i, idx) => (
<MenuItem key={idx} onClick={() => onClick(i.value)}>
{i.name}
</MenuItem>
))
)}
</MenuList>
</Menu>
);
};
export default IconButtonMenu;

View File

@@ -11,11 +11,13 @@ import { Eye, EyeOff } from "react-feather";
type PasswordInputProbs = {
password: [string, React.Dispatch<React.SetStateAction<string>>];
placeHolder?: string;
isRequired?: boolean;
};
function PasswordInput({
password: [pass, setPass],
placeHolder,
isRequired = false,
}: PasswordInputProbs) {
const [showPassword, setShowPassword] = useState(false);
const handleClick = () => setShowPassword(!showPassword);
@@ -27,6 +29,7 @@ function PasswordInput({
type={showPassword ? "text" : "password"}
placeholder={placeHolder || ""}
value={pass}
isRequired
onChange={(e) => setPass(e.target.value)}
/>
<InputRightElement width="4.5rem">

View File

@@ -1,4 +1,8 @@
import { AppSettings, CallHistory } from "src/common/types";
import {
AdvancedAppSettings,
AppSettings,
CallHistory,
} from "src/common/types";
import { Buffer } from "buffer";
// Settings
@@ -21,6 +25,26 @@ export const getSettings = (): AppSettings => {
return {} as AppSettings;
};
// Advanced settings
const ADVANCED_SETTINGS_KET = "AdvancedSettingsKey";
export const saveAddvancedSettings = (settings: AdvancedAppSettings) => {
const encoded = Buffer.from(JSON.stringify(settings), "utf-8").toString(
"base64"
);
localStorage.setItem(ADVANCED_SETTINGS_KET, encoded);
};
export const getAdvancedSettings = (): AdvancedAppSettings => {
const str = localStorage.getItem(ADVANCED_SETTINGS_KET);
if (str) {
const planText = Buffer.from(str, "base64").toString("utf-8");
return JSON.parse(planText) as AdvancedAppSettings;
}
return {} as AdvancedAppSettings;
};
// Call History
const historyKey = "History";
const MAX_HISTORY_COUNT = 20;

View File

@@ -40,12 +40,16 @@ import GreenAvatar from "src/imgs/icons/Avatar-Green.svg";
import "./styles.scss";
import {
deleteCurrentCall,
getAdvancedSettings,
getCurrentCall,
saveCallHistory,
saveCurrentCall,
} from "src/storage";
import { OutGoingCall } from "./outgoing-call";
import { v4 as uuidv4 } from "uuid";
import IconButtonMenu, { IconButtonMenuItems } from "src/components/menu";
import { Application } from "src/api/types";
import { getApplications, getQueues, getRegisteredUser } from "src/api";
type PhoneProbs = {
sipDomain: string;
@@ -79,6 +83,7 @@ export const Phone = ({
const [isStatusDropdownDisabled, setIsStatusDropdownDisabled] =
useState(false);
const [isCallButtonLoading, setIsCallButtonLoading] = useState(false);
const [isAdvanceMode, setIsAdvancedMode] = useState(false);
useEffect(() => {
if (sipDomain && sipUsername && sipPassword && sipServerAddress) {
@@ -88,6 +93,8 @@ export const Phone = ({
setIsConfigured(false);
clientGoOffline();
}
const advancedSettings = getAdvancedSettings();
setIsAdvancedMode(!!advancedSettings.accountSid);
}, [sipDomain, sipUsername, sipPassword, sipServerAddress, sipDisplayName]);
useEffect(() => {
@@ -395,33 +402,86 @@ export const Phone = ({
mt={5}
className={isOnline() ? "" : "blurred"}
>
<HStack spacing={2} align="start" w="full">
<Tooltip label="Call to user">
<IconButton
aria-label="Place call a user"
icon={<Users />}
variant="unstyled"
onClick={handleCallOnHold}
/>
</Tooltip>
<Tooltip label="Call to a queue">
<IconButton
aria-label="Call to a queue"
icon={<GitMerge />}
variant="unstyled"
onClick={handleCallOnHold}
/>
</Tooltip>
{isAdvanceMode && (
<HStack spacing={2} align="start" w="full">
<Tooltip label="Call to user">
<IconButtonMenu
icon={<Users />}
onClick={(value) => {
console.log(value);
}}
onOpen={() => {
return new Promise<IconButtonMenuItems[]>(
(resolve, reject) => {
getRegisteredUser()
.then(({ json }) => {
resolve(
json.map((u) => {
const uName = u.match(/(^.*)@.*/);
return {
name: uName ? uName[1] : u,
value: uName ? uName[1] : u,
};
})
);
})
.catch((err) => reject(err));
}
);
}}
/>
</Tooltip>
<Tooltip label="Call to a queue">
<IconButtonMenu
icon={<GitMerge />}
onClick={(value) => {
console.log(value);
}}
onOpen={() => {
return new Promise<IconButtonMenuItems[]>(
(resolve, reject) => {
getQueues()
.then(({ json }) => {
resolve(
json.map((q) => ({
name: q,
value: q,
}))
);
})
.catch((err) => reject(err));
}
);
}}
/>
</Tooltip>
<Tooltip label="Call to an application">
<IconButton
aria-label="Place call app"
icon={<List />}
variant="unstyled"
onClick={handleCallOnHold}
/>
</Tooltip>
</HStack>
<Tooltip label="Call to an application">
<IconButtonMenu
icon={<List />}
onClick={(value) => {
console.log(value);
}}
onOpen={() => {
return new Promise<IconButtonMenuItems[]>(
(resolve, reject) => {
getApplications()
.then(({ json }) => {
resolve(
json.map((a) => ({
name: a.name,
value: a.application_sid,
}))
);
})
.catch((err) => reject(err));
}
);
}}
/>
</Tooltip>
</HStack>
)}
{isSipClientIdle(callStatus) ? (
<Input

View File

@@ -9,16 +9,47 @@ import {
Text,
VStack,
} from "@chakra-ui/react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { AdvancedAppSettings } from "src/common/types";
import PasswordInput from "src/components/password-input";
import InfoIcon from "src/imgs/icons/Info.svg";
import ResetIcon from "src/imgs/icons/Reset.svg";
import { getAdvancedSettings, saveAddvancedSettings } from "src/storage";
export const AdvanceSettings = () => {
export const AdvancedSettings = () => {
const [apiKey, setApiKey] = useState("");
const [apiServer, setApiServer] = useState("");
const handleSubmit = () => {};
const resetSetting = () => {};
const [accountSid, setAccountSid] = useState("");
useEffect(() => {
const settings = getAdvancedSettings();
if (settings.apiServer) {
setApiServer(settings.apiServer);
}
if (settings.apiKey) {
setApiKey(settings.apiKey);
}
if (settings.accountSid) {
setAccountSid(settings.accountSid);
}
}, []);
const handleSubmit = () => {
const settings: AdvancedAppSettings = {
accountSid,
apiKey,
apiServer,
};
saveAddvancedSettings(settings);
};
const resetSetting = () => {
saveAddvancedSettings({} as AdvancedAppSettings);
setApiKey("");
setApiServer("");
setAccountSid("");
};
return (
<form onSubmit={handleSubmit}>
<VStack spacing={2} w="full" h="full" p={0}>
@@ -29,7 +60,7 @@ export const AdvanceSettings = () => {
w="full"
p={0}
>
<FormControl id="jambonz_server_address">
<FormControl id="jambonz_api_server">
<FormLabel>Jambonz API Server Base URL</FormLabel>
<Input
type="text"
@@ -39,9 +70,19 @@ export const AdvanceSettings = () => {
onChange={(e) => setApiServer(e.target.value)}
/>
</FormControl>
<FormControl id="jambonz_account_sid">
<FormLabel>Jambonz Account Sid</FormLabel>
<Input
type="text"
placeholder="Account Sid"
isRequired
value={accountSid}
onChange={(e) => setAccountSid(e.target.value)}
/>
</FormControl>
<FormControl id="api_key">
<FormLabel>API Key</FormLabel>
<PasswordInput password={[apiKey, setApiKey]} />
<PasswordInput password={[apiKey, setApiKey]} isRequired />
</FormControl>
</VStack>
<Button colorScheme="jambonz" type="submit" w="full">
@@ -66,4 +107,4 @@ export const AdvanceSettings = () => {
);
};
export default AdvanceSettings;
export default AdvancedSettings;

View File

@@ -1,8 +1,8 @@
import { Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
import React, { useEffect, useState } from "react";
import React from "react";
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
import BasicSettings from "./basic";
import AdvanceSettings from "./advance";
import AdvancedSettings from "./advanced";
export const Settings = () => {
return (
@@ -17,7 +17,7 @@ export const Settings = () => {
<BasicSettings />
</TabPanel>
<TabPanel p={0}>
<AdvanceSettings />
<AdvancedSettings />
</TabPanel>
</TabPanels>
</Tabs>