Flatten
This commit is contained in:
parent
8f165d15d2
commit
c620e4bf25
264 changed files with 9983 additions and 2280 deletions
|
|
@ -1,9 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { Box, Grid, Container, IconButton } from "@mui/material";
|
||||
import { Apple as AppleIcon, Google as GoogleIcon } from "@mui/icons-material";
|
||||
import { FC, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Container,
|
||||
IconButton,
|
||||
Typography,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Apple as AppleIcon,
|
||||
Google as GoogleIcon,
|
||||
Key as KeyIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import LinkLogo from "public/link-logo-small.png";
|
||||
import { colors } from "app/_styles/theme";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
type LoginProps = {
|
||||
session: any;
|
||||
|
|
@ -14,62 +29,198 @@ export const Login: FC<LoginProps> = ({ session }) => {
|
|||
typeof window !== "undefined" && window.location.origin
|
||||
? window.location.origin
|
||||
: "";
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const params = useSearchParams();
|
||||
const error = params.get("error");
|
||||
const { darkGray, cdrLinkOrange, white } = colors;
|
||||
const buttonStyles = {
|
||||
borderRadius: 500,
|
||||
width: "100%",
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
backgroundColor: white,
|
||||
"&:hover": {
|
||||
color: white,
|
||||
backgroundColor: cdrLinkOrange,
|
||||
},
|
||||
};
|
||||
const fieldStyles = {
|
||||
"& label.Mui-focused": {
|
||||
color: cdrLinkOrange,
|
||||
},
|
||||
"& .MuiInput-underline:after": {
|
||||
borderBottomColor: cdrLinkOrange,
|
||||
},
|
||||
"& .MuiFilledInput-underline:after": {
|
||||
borderBottomColor: cdrLinkOrange,
|
||||
},
|
||||
"& .MuiOutlinedInput-root": {
|
||||
"&.Mui-focused fieldset": {
|
||||
borderColor: cdrLinkOrange,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container direction="row-reverse" sx={{ p: 3 }}>
|
||||
<Grid item />
|
||||
</Grid>
|
||||
<Container maxWidth="md" sx={{ mt: 3, mb: 20 }}>
|
||||
<Box sx={{ backgroundColor: darkGray, height: "100vh" }}>
|
||||
<Container maxWidth="md" sx={{ p: 10 }}>
|
||||
<Grid container spacing={2} direction="column" alignItems="center">
|
||||
<Grid item>
|
||||
<Box sx={{ maxWidth: 200 }} />
|
||||
</Grid>
|
||||
<Grid item sx={{ textAlign: "center" }} />
|
||||
|
||||
<Grid item>
|
||||
{!session ? (
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
sx={{ width: 450, mt: 1 }}
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Grid item>
|
||||
<Box
|
||||
sx={{
|
||||
width: "70px",
|
||||
height: "70px",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
<IconButton
|
||||
sx={buttonStyles}
|
||||
onClick={() =>
|
||||
signIn("google", {
|
||||
callbackUrl: `${origin}/setup`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<GoogleIcon sx={{ mr: 1 }} />
|
||||
Google
|
||||
</IconButton>
|
||||
<Image
|
||||
src={LinkLogo}
|
||||
alt="Link logo"
|
||||
width={70}
|
||||
height={70}
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
filter: "grayscale(100) brightness(100)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: 36,
|
||||
color: "white",
|
||||
fontWeight: 700,
|
||||
mt: 1,
|
||||
ml: 0.5,
|
||||
fontFamily: "Poppins",
|
||||
}}
|
||||
>
|
||||
CDR Link
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
{!session ? (
|
||||
<Container
|
||||
maxWidth="xs"
|
||||
sx={{
|
||||
p: 3,
|
||||
mt: 3,
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
>
|
||||
{error ? (
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
<Box sx={{ backgroundColor: "red", p: 3 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{`${error} error`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
) : null}
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
<IconButton
|
||||
sx={buttonStyles}
|
||||
onClick={() =>
|
||||
signIn("google", {
|
||||
callbackUrl: `${origin}/setup`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<GoogleIcon sx={{ mr: 1 }} />
|
||||
Sign in with Google
|
||||
</IconButton>
|
||||
</Grid>
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
<IconButton
|
||||
aria-label="Sign in with Apple"
|
||||
sx={buttonStyles}
|
||||
onClick={() =>
|
||||
signIn("apple", {
|
||||
callbackUrl: `${window.location.origin}/setup`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<AppleIcon sx={{ mr: 1 }} />
|
||||
Sign in with Apple
|
||||
</IconButton>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
color: white,
|
||||
textAlign: "center",
|
||||
mt: 3,
|
||||
}}
|
||||
>
|
||||
⸺ or ⸺
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
<TextField
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
label="Email"
|
||||
variant="filled"
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{ ...fieldStyles, backgroundColor: white }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item sx={{ ...fieldStyles, width: "100%" }}>
|
||||
<TextField
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
label="Password"
|
||||
variant="filled"
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{ backgroundColor: white }}
|
||||
type="password"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
<IconButton
|
||||
sx={buttonStyles}
|
||||
onClick={() =>
|
||||
signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
callbackUrl: `${origin}/setup`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<KeyIcon sx={{ mr: 1 }} />
|
||||
Sign in with Zammad credentials
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{/*
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
<IconButton
|
||||
sx={buttonStyles}
|
||||
onClick={() =>
|
||||
signIn("apple", {
|
||||
callbackUrl: `${window.location.origin}/setup`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<AppleIcon sx={{ mr: 1 }} />
|
||||
</IconButton>
|
||||
</Grid>*/}
|
||||
<Grid item sx={{ mt: 2 }} />
|
||||
</Grid>
|
||||
</Container>
|
||||
) : null}
|
||||
{session ? (
|
||||
<Box component="h4">
|
||||
|
|
@ -79,6 +230,6 @@ export const Login: FC<LoginProps> = ({ session }) => {
|
|||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
14
apps/link/app/(main)/_components/ClientOnly.tsx
Normal file
14
apps/link/app/(main)/_components/ClientOnly.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
type ClientOnlyProps = { children: JSX.Element };
|
||||
const ClientOnly = (props: ClientOnlyProps) => {
|
||||
const { children } = props;
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default dynamic(() => Promise.resolve(ClientOnly), {
|
||||
ssr: false,
|
||||
});
|
||||
|
|
@ -17,12 +17,14 @@ import {
|
|||
import {
|
||||
FeaturedPlayList as FeaturedPlayListIcon,
|
||||
Person as PersonIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
Insights as InsightsIcon,
|
||||
Logout as LogoutIcon,
|
||||
Cottage as CottageIcon,
|
||||
Settings as SettingsIcon,
|
||||
ExpandCircleDown as ExpandCircleDownIcon,
|
||||
Dvr as DvrIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
LibraryBooks as LibraryBooksIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
|
@ -161,7 +163,10 @@ interface SidebarProps {
|
|||
export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
|
||||
const pathname = usePathname();
|
||||
const { data: session } = useSession();
|
||||
console.log({ session });
|
||||
const username = session?.user?.name || "User";
|
||||
// @ts-ignore
|
||||
const roles = session?.user?.roles || [];
|
||||
const { data: overviewData, error: overviewError }: any = useSWR(
|
||||
{
|
||||
document: getTicketOverviewCountsQuery,
|
||||
|
|
@ -419,17 +424,25 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
|
|||
<MenuItem
|
||||
name="Knowledge Base"
|
||||
href="/knowledge"
|
||||
Icon={CottageIcon}
|
||||
Icon={LibraryBooksIcon}
|
||||
iconSize={20}
|
||||
selected={pathname.endsWith("/knowledge")}
|
||||
open={open}
|
||||
/>
|
||||
<MenuItem
|
||||
name="Reporting"
|
||||
href="/reporting"
|
||||
Icon={AssessmentIcon}
|
||||
iconSize={20}
|
||||
selected={pathname.endsWith("/reporting")}
|
||||
open={open}
|
||||
/>
|
||||
<MenuItem
|
||||
name="Leafcutter"
|
||||
href="/leafcutter"
|
||||
Icon={AnalyticsIcon}
|
||||
Icon={InsightsIcon}
|
||||
iconSize={20}
|
||||
selected={pathname.endsWith("/leafcutter")}
|
||||
selected={false}
|
||||
open={open}
|
||||
/>
|
||||
<Collapse
|
||||
|
|
@ -471,7 +484,7 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
|
|||
<MenuItem
|
||||
name="About"
|
||||
href="/leafcutter/about"
|
||||
Icon={AnalyticsIcon}
|
||||
Icon={InsightsIcon}
|
||||
iconSize={0}
|
||||
selected={pathname.endsWith("/leafcutter/about")}
|
||||
open={open}
|
||||
|
|
@ -486,49 +499,57 @@ export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
|
|||
selected={pathname.endsWith("/profile")}
|
||||
open={open}
|
||||
/>
|
||||
<MenuItem
|
||||
name="Admin"
|
||||
href="/admin/zammad"
|
||||
Icon={SettingsIcon}
|
||||
iconSize={20}
|
||||
open={open}
|
||||
/>
|
||||
<Collapse
|
||||
in={pathname.startsWith("/admin/")}
|
||||
timeout="auto"
|
||||
unmountOnExit
|
||||
onClick={undefined}
|
||||
>
|
||||
<List component="div" disablePadding>
|
||||
{roles.includes("admin") && (
|
||||
<>
|
||||
<MenuItem
|
||||
name="Zammad Settings"
|
||||
name="Admin"
|
||||
href="/admin/zammad"
|
||||
Icon={FeaturedPlayListIcon}
|
||||
iconSize={0}
|
||||
selected={pathname.endsWith("/admin/zammad")}
|
||||
Icon={SettingsIcon}
|
||||
iconSize={20}
|
||||
open={open}
|
||||
/>
|
||||
<MenuItem
|
||||
name="Metamigo"
|
||||
href="/admin/metamigo"
|
||||
Icon={FeaturedPlayListIcon}
|
||||
iconSize={0}
|
||||
selected={pathname.endsWith("/admin/metamigo")}
|
||||
open={open}
|
||||
/>
|
||||
<MenuItem
|
||||
name="Label Studio"
|
||||
href="/admin/label-studio"
|
||||
Icon={FeaturedPlayListIcon}
|
||||
iconSize={0}
|
||||
selected={pathname.endsWith("/admin/label-studio")}
|
||||
open={open}
|
||||
/>
|
||||
</List>
|
||||
</Collapse>
|
||||
<Collapse
|
||||
in={pathname.startsWith("/admin/")}
|
||||
timeout="auto"
|
||||
unmountOnExit
|
||||
onClick={undefined}
|
||||
>
|
||||
<List component="div" disablePadding>
|
||||
<MenuItem
|
||||
name="Zammad Settings"
|
||||
href="/admin/zammad"
|
||||
Icon={FeaturedPlayListIcon}
|
||||
iconSize={0}
|
||||
selected={pathname.endsWith("/admin/zammad")}
|
||||
open={open}
|
||||
/>
|
||||
{false && roles.includes("metamigo") && (
|
||||
<MenuItem
|
||||
name="Metamigo"
|
||||
href="/admin/metamigo"
|
||||
Icon={FeaturedPlayListIcon}
|
||||
iconSize={0}
|
||||
selected={pathname.endsWith("/admin/metamigo")}
|
||||
open={open}
|
||||
/>
|
||||
)}
|
||||
{roles.includes("label_studio") && (
|
||||
<MenuItem
|
||||
name="Label Studio"
|
||||
href="/admin/label-studio"
|
||||
Icon={FeaturedPlayListIcon}
|
||||
iconSize={0}
|
||||
selected={pathname.endsWith("/admin/label-studio")}
|
||||
open={open}
|
||||
/>
|
||||
)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</>
|
||||
)}
|
||||
<MenuItem
|
||||
name="Zammad Interface"
|
||||
href="/proxy/zammad"
|
||||
href="/zammad"
|
||||
Icon={DvrIcon}
|
||||
iconSize={20}
|
||||
open={open}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
|
|||
}) => {
|
||||
const router = useRouter();
|
||||
const [display, setDisplay] = useState("none");
|
||||
const url = `/proxy/zammad${path}`;
|
||||
console.log({ url });
|
||||
const url = `/zammad${path}`;
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
|
|
@ -33,8 +32,7 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
|
|||
linkElement.contentDocument &&
|
||||
linkElement.contentDocument?.querySelector &&
|
||||
linkElement.contentDocument.querySelector("#navigation") &&
|
||||
linkElement.contentDocument.querySelector("body") &&
|
||||
linkElement.contentDocument.querySelector(".sidebar")
|
||||
linkElement.contentDocument.querySelector("body")
|
||||
) {
|
||||
// @ts-ignore
|
||||
linkElement.contentDocument.querySelector("#navigation").style =
|
||||
|
|
@ -43,7 +41,10 @@ export const ZammadWrapper: FC<ZammadWrapperProps> = ({
|
|||
linkElement.contentDocument.querySelector("body").style =
|
||||
"font-family: Arial";
|
||||
|
||||
if (hideSidebar) {
|
||||
if (
|
||||
hideSidebar &&
|
||||
linkElement.contentDocument.querySelector(".sidebar")
|
||||
) {
|
||||
// @ts-ignore
|
||||
linkElement.contentDocument.querySelector(".sidebar").style =
|
||||
"display: none";
|
||||
|
|
|
|||
16
apps/link/app/(main)/admin/metamigo/_components/Admin.tsx
Normal file
16
apps/link/app/(main)/admin/metamigo/_components/Admin.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { ApolloProvider } from "@apollo/client";
|
||||
import { apolloClient } from "../_lib/apollo-client";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const MetamigoAdmin = dynamic(() => import("./MetamigoAdmin"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export const Admin: FC = () => (
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<MetamigoAdmin />
|
||||
</ApolloProvider>
|
||||
);
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useEffect } from "react";
|
||||
import { CircularProgress, Typography, Grid } from "@mui/material";
|
||||
import { signIn, signOut, getSession } from "next-auth/react";
|
||||
import { useLogin, useTranslate } from "react-admin";
|
||||
|
||||
export const authProvider = {
|
||||
login(o: any) {
|
||||
if (o.ok) return Promise.resolve();
|
||||
return Promise.reject();
|
||||
},
|
||||
async logout() {
|
||||
const session = await getSession();
|
||||
if (session) {
|
||||
await signOut();
|
||||
}
|
||||
},
|
||||
checkError(e: any) {
|
||||
if (e.graphQLErrors && e.graphQLErrors.length > 0) {
|
||||
const permDenied = e.graphQLErrors.some((e: any) =>
|
||||
e.message.match(/.*permission denied.*/),
|
||||
);
|
||||
if (permDenied)
|
||||
// eslint-disable-next-line prefer-promise-reject-errors
|
||||
return Promise.reject({ message: "auth.permissionDenied" });
|
||||
}
|
||||
|
||||
if (e.networkError && e.networkError.statusCode === 401) {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
async checkAuth() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
throw new Error("Invalid session");
|
||||
}
|
||||
},
|
||||
async getIdentity() {
|
||||
const session = await getSession();
|
||||
if (!session) throw new Error("Invalid session");
|
||||
|
||||
return {
|
||||
id: session.user?.email,
|
||||
fullName: session.user?.name,
|
||||
avatar: session.user?.image,
|
||||
};
|
||||
},
|
||||
getPermissions: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
export const AdminLogin: FC = () => {
|
||||
const reactAdminLogin = useLogin();
|
||||
const translate = useTranslate();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const session = await getSession();
|
||||
if (session) {
|
||||
reactAdminLogin({ ok: true });
|
||||
} else {
|
||||
signIn();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={5}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style={{ minHeight: "100vh" }}
|
||||
>
|
||||
<Grid item xs={3}>
|
||||
<Typography variant="h4" color="textSecondary">
|
||||
{translate("auth.loggingIn")}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={3}>
|
||||
<CircularProgress size={80} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
.input {
|
||||
width: 40px;
|
||||
height: 60px;
|
||||
margin: 5px;
|
||||
font-size: 1.4rem;
|
||||
padding: 0 9px 0 12px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: white;
|
||||
font-weight: 400;
|
||||
color: rgba(59, 59, 59, 0.788);
|
||||
-webkit-box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6;
|
||||
box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.hyphen {
|
||||
background: black;
|
||||
height: 0.1em;
|
||||
width: 1em;
|
||||
margin: 0 0.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
import { forwardRef } from "react";
|
||||
import useDigitInput, { InputAttributes } from "react-digit-input";
|
||||
import styles from "./DigitInput.module.css";
|
||||
|
||||
const DigitInputElement = forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<InputAttributes, "ref"> & {
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
>(({ ...props }, ref): any => (
|
||||
<>
|
||||
<input
|
||||
aria-label="verification code"
|
||||
className={styles.input}
|
||||
{...props}
|
||||
ref={ref}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</>
|
||||
));
|
||||
|
||||
const DigitSeparator = forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<InputAttributes, "ref"> & {
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ ...props }, ref): any => (
|
||||
<>
|
||||
<span className={styles.hyphen} ref={ref} />
|
||||
</>
|
||||
)
|
||||
);
|
||||
|
||||
export const SixDigitInput = ({ value, onChange }: any) => {
|
||||
const digits = useDigitInput({
|
||||
acceptedCharacters: /^\d$/,
|
||||
length: 6,
|
||||
value,
|
||||
onChange,
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.group}>
|
||||
<DigitInputElement autoFocus {...digits[0]} />
|
||||
<DigitInputElement {...digits[1]} />
|
||||
<DigitInputElement {...digits[2]} />
|
||||
<DigitSeparator />
|
||||
<DigitInputElement {...digits[3]} />
|
||||
<DigitInputElement {...digits[4]} />
|
||||
<DigitInputElement {...digits[5]} />
|
||||
</div>
|
||||
<pre hidden>
|
||||
<code>{value}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// @ts-nocheck
|
||||
|
||||
"use client";
|
||||
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { Admin, Resource } from "react-admin";
|
||||
import { useApolloClient } from "@apollo/client";
|
||||
import polyglotI18nProvider from "ra-i18n-polyglot";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
import { metamigoDataProvider } from "../_lib/dataprovider";
|
||||
import { theme } from "./layout/themes";
|
||||
import { Layout } from "./layout";
|
||||
import englishMessages from "../_i18n/en";
|
||||
import whatsappBots from "./whatsapp/bots";
|
||||
import whatsappMessages from "./whatsapp/messages";
|
||||
import whatsappAttachments from "./whatsapp/attachments";
|
||||
import voiceLines from "./voice/voicelines";
|
||||
import signalBots from "./signal/bots";
|
||||
import voiceProviders from "./voice/providers";
|
||||
import webhooks from "./webhooks";
|
||||
import { AdminLogin, authProvider } from "./AdminLogin";
|
||||
|
||||
const i18nProvider = polyglotI18nProvider(
|
||||
(_locale: any) => englishMessages,
|
||||
"en",
|
||||
);
|
||||
|
||||
const MetamigoAdmin: FC = () => {
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
const [dataProvider, setDataProvider] = useState(null);
|
||||
const client = useApolloClient();
|
||||
const muiTheme = createTheme(theme);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const dataProvider = await metamigoDataProvider(client);
|
||||
// @ts-ignore
|
||||
setDataProvider(() => dataProvider);
|
||||
})();
|
||||
}, [client]);
|
||||
return (
|
||||
dataProvider && (
|
||||
<Admin
|
||||
disableTelemetry
|
||||
dataProvider={dataProvider}
|
||||
layout={Layout}
|
||||
i18nProvider={i18nProvider}
|
||||
// @ts-ignore
|
||||
loginPage={AdminLogin}
|
||||
// @ts-ignore
|
||||
authProvider={authProvider}
|
||||
>
|
||||
<Resource name="webhooks" {...webhooks} />
|
||||
<Resource name="whatsappBots" {...whatsappBots} />
|
||||
<Resource name="whatsappMessages" {...whatsappMessages} />
|
||||
<Resource name="whatsappAttachments" {...whatsappAttachments} />
|
||||
<Resource name="signalBots" {...signalBots} />
|
||||
<Resource name="voiceProviders" {...voiceProviders} />
|
||||
<Resource name="voiceLines" {...voiceLines} />
|
||||
<Resource name="languages" />
|
||||
</Admin>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default MetamigoAdmin;
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { Grid } from "@mui/material";
|
||||
import Iframe from "react-iframe";
|
||||
|
||||
type MetamigoWrapperProps = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const MetamigoWrapper: FC<MetamigoWrapperProps> = ({ path }) => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
sx={{ height: "100%", width: "100%" }}
|
||||
direction="column"
|
||||
>
|
||||
<Grid item sx={{ height: "100vh", width: "100%" }}>
|
||||
<Iframe
|
||||
id="metamigo"
|
||||
url="/proxy/metamigo"
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameBorder={0}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { FC, PropsWithChildren } from "react";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export const MultiProvider: FC<PropsWithChildren> = ({ children }) => (
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
);
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
// import { makeStyles } from "@mui/styles";
|
||||
import {
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
Edit,
|
||||
ReferenceInput,
|
||||
SelectInput,
|
||||
DateInput,
|
||||
Toolbar,
|
||||
DeleteButton,
|
||||
EditProps,
|
||||
} from "react-admin";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
/*
|
||||
const useStyles = makeStyles((_theme: any) => ({
|
||||
defaultToolbar: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
}));
|
||||
*/
|
||||
type AccountEditToolbarProps = {
|
||||
record?: any;
|
||||
};
|
||||
|
||||
const AccountEditToolbar: FC<AccountEditToolbarProps> = (props) => {
|
||||
const { data: session } = useSession();
|
||||
const classes: any = {}; // useStyles(props);
|
||||
return (
|
||||
<Toolbar className={classes.defaultToolbar} {...props}>
|
||||
<DeleteButton disabled={session?.user?.email === props.record?.userId} />
|
||||
</Toolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountTitle = ({ record }: { record?: any }) => {
|
||||
let title = "";
|
||||
if (record) title = record.name ?? record.email;
|
||||
return <span>Account {title}</span>;
|
||||
};
|
||||
|
||||
export const AccountEdit: FC<EditProps> = (props) => (
|
||||
<Edit title={<AccountTitle />} {...props}>
|
||||
<SimpleForm toolbar={<AccountEditToolbar />}>
|
||||
<TextInput disabled source="id" />
|
||||
<ReferenceInput source="userId" reference="users">
|
||||
<SelectInput disabled optionText="email" />
|
||||
</ReferenceInput>
|
||||
<TextInput disabled source="providerType" />
|
||||
<TextInput disabled source="providerId" />
|
||||
<TextInput disabled source="providerAccountId" />
|
||||
<DateInput disabled source="createdAt" />
|
||||
<DateInput disabled source="updatedAt" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
|
||||
export default AccountEdit;
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import {
|
||||
List,
|
||||
Datagrid,
|
||||
DateField,
|
||||
TextField,
|
||||
ReferenceField,
|
||||
DeleteButton,
|
||||
ListProps,
|
||||
} from "react-admin";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
type DeleteNotSelfButtonProps = {
|
||||
record?: any;
|
||||
};
|
||||
|
||||
const DeleteNotSelfButton: FC<DeleteNotSelfButtonProps> = (props) => {
|
||||
const { data: session } = useSession();
|
||||
return (
|
||||
// @ts-ignore
|
||||
<DeleteButton
|
||||
disabled={session?.user?.email === props.record.userId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountList: FC<ListProps> = (props) => (
|
||||
<List {...props} exporter={false}>
|
||||
<Datagrid rowClick="edit">
|
||||
<ReferenceField source="userId" reference="users">
|
||||
<TextField source="email" />
|
||||
</ReferenceField>
|
||||
<TextField source="providerType" />
|
||||
<TextField source="providerId" />
|
||||
<TextField source="providerAccountId" />
|
||||
<DateField source="createdAt" />
|
||||
<DateField source="updatedAt" />
|
||||
<DeleteNotSelfButton />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export default AccountList;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
/* eslint-disable import/no-named-as-default */
|
||||
/* eslint-disable import/no-anonymous-default-export */
|
||||
import AccountIcon from "@mui/icons-material/AccountTree";
|
||||
import AccountList from "./AccountList";
|
||||
import AccountEdit from "./AccountEdit";
|
||||
|
||||
export default {
|
||||
list: AccountList,
|
||||
edit: AccountEdit,
|
||||
icon: AccountIcon,
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { AppBar, UserMenu, MenuItemLink, useTranslate } from "react-admin";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import { colors } from "app/_styles/theme";
|
||||
// import { makeStyles } from "@mui/styles";
|
||||
/*
|
||||
const useStyles = makeStyles({
|
||||
title: {
|
||||
flex: 1,
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
*/
|
||||
// eslint-disable-next-line react/display-name
|
||||
const ConfigurationMenu = forwardRef<any, any>((props, ref) => {
|
||||
const translate = useTranslate();
|
||||
return (
|
||||
<MenuItemLink
|
||||
ref={ref}
|
||||
to="/configuration"
|
||||
primaryText={translate("pos.configuration")}
|
||||
leftIcon={<SettingsIcon />}
|
||||
onClick={props.onClick}
|
||||
sidebarIsOpen
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const CustomAppBar = (props: any) => {
|
||||
const classes: any = {}; // useStyles();
|
||||
const { darkLavender } = colors;
|
||||
return (
|
||||
<AppBar
|
||||
{...props}
|
||||
elevation={0}
|
||||
userMenu={<div />}
|
||||
position="sticky"
|
||||
sx={{ mt: -1, backgroundColor: darkLavender }}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="inherit"
|
||||
className={classes.title}
|
||||
id="react-admin-title"
|
||||
/>
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomAppBar;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { Layout as RaLayout, LayoutProps, Sidebar } from "react-admin";
|
||||
import AppBar from "./AppBar";
|
||||
import { Menu } from "./Menu";
|
||||
import { theme } from "./themes";
|
||||
|
||||
const CustomSidebar = (props: any) => <Sidebar {...props} size={200} />;
|
||||
|
||||
export const Layout = (props: LayoutProps) => (
|
||||
<RaLayout
|
||||
{...props}
|
||||
appBar={AppBar}
|
||||
menu={Menu as any}
|
||||
sidebar={CustomSidebar}
|
||||
// @ts-ignore
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
106
apps/link/app/(main)/admin/metamigo/_components/layout/Logo.tsx
Normal file
106
apps/link/app/(main)/admin/metamigo/_components/layout/Logo.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"use client";
|
||||
|
||||
import { SVGProps } from "react";
|
||||
|
||||
const Logo = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg width="220.001" height="43.659" {...props}>
|
||||
<path d="M59.39 24.586h4.6v8.512c-1.058.2-3.743.57-5.742.57-6.398 0-7.74-3.77-7.74-11.452 0-7.827 1.4-11.54 7.797-11.54 3.627 0 8.597.828 8.597.828l.115-2.542s-4.885-1.056-9.083-1.056c-8.312 0-10.626 5.112-10.626 14.31 0 8.968 2.228 14.167 10.711 14.167 3.028 0 8.17-.8 8.998-.971V21.816H59.39zm13.14 11.397h2.998V21.302s3.514-1.943 7.284-2.714V15.56c-3.828.743-7.312 3.142-7.312 3.142v-2.713h-2.97zm27.962-13.967c0-4.342-1.913-6.427-6.455-6.427-3.427 0-7.826.885-7.826.885l.114 2.285s4.77-.542 7.57-.542c2.4 0 3.598 1 3.598 3.799v1.742l-6.284.6c-4.113.4-6.112 2.056-6.112 5.912 0 4.028 2 6.113 5.627 6.113 3.6 0 7.198-1.6 7.198-1.6 1.2 1.2 2.656 1.6 4.77 1.6l.114-2.37c-1.285-.144-2.228-.6-2.314-1.743zm-2.999 3.998v6.599s-3.313 1.256-6.284 1.256c-2.028 0-3.027-1.37-3.027-3.684 0-2.2.942-3.37 3.4-3.6zm17.738-10.425c-2.828 0-5.855 1.4-5.855 1.4V7.277h-2.97v28.677s4.283.429 6.683.429c7.283 0 9.425-2.77 9.425-10.711 0-7.198-1.828-10.083-7.283-10.083zm-2.2 18.109c-1.056 0-3.655-.2-3.655-.2V19.416s2.8-1.142 5.54-1.142c3.514 0 4.57 2.228 4.57 7.398 0 5.598-.97 8.026-6.454 8.026zm28.535-11.682c0-4.342-1.942-6.427-6.455-6.427-3.428 0-7.826.885-7.826.885l.114 2.285s4.77-.542 7.57-.542c2.4 0 3.598 1 3.598 3.799v1.742l-6.284.6c-4.113.4-6.112 2.056-6.112 5.912 0 4.028 2 6.113 5.626 6.113 3.6 0 7.198-1.6 7.198-1.6 1.2 1.2 2.628 1.6 4.77 1.6l.115-2.37c-1.286-.144-2.257-.6-2.314-1.743zm-3 3.998v6.599s-3.34 1.256-6.283 1.256c-2.057 0-3.056-1.37-3.056-3.684 0-2.2.97-3.37 3.4-3.6zm24.25-18.737h-2.94v8.826c-.6-.114-3.2-.514-4.914-.514-6.084 0-8.369 3.513-8.369 10.568 0 8.626 3.285 10.226 7.198 10.226 3 0 6.084-1.771 6.084-1.771v1.37h2.942zm-8.654 26.42c-2.37 0-4.484-1.084-4.484-7.54 0-5.198 1.228-7.97 5.427-7.97 1.657 0 4.113.373 4.77.487v13.539s-2.885 1.485-5.713 1.485zM176.3 15.59c-6.313 0-8.54 3.285-8.54 10.168 0 7.255 1.827 10.626 8.54 10.626 6.77 0 8.57-3.37 8.57-10.626 0-6.883-2.2-10.168-8.57-10.168zm0 18.195c-4.713 0-5.484-2.371-5.484-8.027 0-5.57 1.256-7.57 5.484-7.57 4.284 0 5.484 2 5.484 7.57 0 5.656-.714 8.027-5.484 8.027zm13.453 2.199h3V21.303s3.512-1.943 7.254-2.714V15.56c-3.828.743-7.312 3.142-7.312 3.142V15.99h-2.942zm27.934-13.967c0-4.342-1.913-6.427-6.426-6.427-3.456 0-7.855.885-7.855.885l.143 2.285s4.741-.542 7.54-.542c2.4 0 3.6 1 3.6 3.799v1.742l-6.285.6c-4.113.4-6.112 2.056-6.112 5.912 0 4.028 2 6.113 5.655 6.113 3.6 0 7.198-1.6 7.198-1.6 1.2 1.2 2.628 1.6 4.742 1.6l.114-2.37c-1.257-.144-2.228-.6-2.314-1.743zm-2.999 3.998v6.599s-3.313 1.256-6.284 1.256c-2.028 0-3.027-1.37-3.027-3.684 0-2.2.97-3.37 3.4-3.6z" />
|
||||
<defs>
|
||||
<linearGradient
|
||||
gradientTransform="rotate(25)"
|
||||
id="a"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="0"
|
||||
>
|
||||
<stop offset="0%" stopColor="#8C48D2" />
|
||||
<stop offset="100%" stopColor="#CF705A" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
xlinkHref="#a"
|
||||
id="c"
|
||||
gradientTransform="scale(.7746 1.291)"
|
||||
x1="15.492"
|
||||
y1="4.648"
|
||||
x2="23.238"
|
||||
y2="4.648"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<linearGradient
|
||||
xlinkHref="#a"
|
||||
id="d"
|
||||
gradientTransform="scale(1.27 .7874)"
|
||||
x1="7.874"
|
||||
y1="15.24"
|
||||
x2="15.748"
|
||||
y2="15.24"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<linearGradient
|
||||
xlinkHref="#a"
|
||||
id="e"
|
||||
gradientTransform="scale(.91287 1.09545)"
|
||||
x1="10.954"
|
||||
y1="7.303"
|
||||
x2="21.909"
|
||||
y2="7.303"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<linearGradient
|
||||
xlinkHref="#a"
|
||||
id="f"
|
||||
gradientTransform="scale(1.13606 .88024)"
|
||||
x1="3.521"
|
||||
y1="13.576"
|
||||
x2="22.886"
|
||||
y2="13.576"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<linearGradient
|
||||
xlinkHref="#a"
|
||||
id="g"
|
||||
gradientTransform="scale(1.029 .97183)"
|
||||
x1="5.831"
|
||||
y1="1.029"
|
||||
x2="23.324"
|
||||
y2="1.029"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<linearGradient
|
||||
xlinkHref="#a"
|
||||
id="b"
|
||||
gradientTransform="scale(.88647 1.12807)"
|
||||
x1="4.512"
|
||||
y1=".886"
|
||||
x2="29.33"
|
||||
y2=".886"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
</defs>
|
||||
<g transform="translate(-6.238 -1.56) scale(1.55946)" fill="url(#b)">
|
||||
<path
|
||||
d="M12 9v4a3 3 0 006 0V9a3 3 0 00-6 0zm3-2a2 2 0 012 2v4a2 2 0 11-4 0V9a2 2 0 012-2z"
|
||||
fill="url(#c)"
|
||||
/>
|
||||
<path
|
||||
d="M10 13.2a5 5 0 0010 0v-.7a.5.5 0 10-1 0v.7a4 4 0 11-8 0v-.7a.5.5 0 10-1 0z"
|
||||
fill="url(#d)"
|
||||
/>
|
||||
<path
|
||||
d="M19.5 13a.5.5 0 100-1h-9a.5.5 0 100 1zm-3 6a.5.5 0 110 1h-3a.5.5 0 110-1h1v-1h1v1zm-3-10a.5.5 0 000-1h-1v1zm0 2a.5.5 0 000-1h-1v1zm3 0a.5.5 0 110-1h1v1zm0-2a.5.5 0 110-1h1v1z"
|
||||
fill="url(#e)"
|
||||
/>
|
||||
<path
|
||||
d="M25.947 14.272a.51.51 0 01.053.23v13.994a.5.5 0 01-.5.5h-21a.5.5 0 01-.5-.5V14.502a.502.502 0 01.2-.406L7 11.95v1.26l-2 1.533v1.253l6.667 5h6.666l6.667-5v-1.253l-2-1.533v-1.26l2.8 2.146a.502.502 0 01.147.176zM10.739 21.55L5 27.29V17.245l5.739 4.304zm.968.446h6.586l6 6H5.707zm7.554-.446L25 17.246V27.29l-5.739-5.739z"
|
||||
fill="url(#f)"
|
||||
/>
|
||||
<path
|
||||
d="M24 6.2a.5.5 0 00-.146-.354l-4.7-4.7A.5.5 0 0018.8 1H6.5a.5.5 0 00-.5.5V18h1V2h11v4.5a.5.5 0 00.5.5H23v11h1zM19 6V2.41L22.59 6z"
|
||||
fill="url(#g)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Logo;
|
||||
100
apps/link/app/(main)/admin/metamigo/_components/layout/Menu.tsx
Normal file
100
apps/link/app/(main)/admin/metamigo/_components/layout/Menu.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
import { FC, useState } from "react";
|
||||
import VoiceIcon from "@mui/icons-material/PhoneInTalk";
|
||||
import { Box } from "@mui/material";
|
||||
// import { useTheme } from "@mui/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTranslate, MenuItemLink } from "react-admin";
|
||||
import webhooks from "../webhooks";
|
||||
import voiceLines from "../voice/voicelines";
|
||||
import voiceProviders from "../voice/providers";
|
||||
import whatsappBots from "../whatsapp/bots";
|
||||
import signalBots from "../signal/bots";
|
||||
import { SubMenu } from "./SubMenu";
|
||||
|
||||
type MenuName = "menuVoice" | "menuSecurity";
|
||||
|
||||
export const Menu: FC = ({ onMenuClick, logout, dense = false }: any) => {
|
||||
const [state, setState] = useState({
|
||||
menuVoice: false,
|
||||
menuSecurity: false,
|
||||
});
|
||||
const translate = useTranslate();
|
||||
const theme: any = {}; // useTheme();
|
||||
// @ts-ignore
|
||||
const isXSmall = false; // useMediaQuery(theme?.breakpoints?.down("xs"));
|
||||
const open = true; // useSelector((state: any) => state.admin.ui.sidebarOpen);
|
||||
|
||||
const handleToggle = (menu: MenuName) => {
|
||||
setState((state: any) => ({ ...state, [menu]: !state[menu] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box mt={1}>
|
||||
<MenuItemLink
|
||||
to={`/whatsappbots`}
|
||||
primaryText={translate(`pos.menu.whatsapp`, {
|
||||
smart_count: 2,
|
||||
})}
|
||||
leftIcon={<whatsappBots.icon />}
|
||||
onClick={onMenuClick}
|
||||
sidebarIsOpen={open}
|
||||
dense={dense}
|
||||
/>
|
||||
<MenuItemLink
|
||||
to={`/signalbots`}
|
||||
primaryText={translate(`pos.menu.signal`, {
|
||||
smart_count: 2,
|
||||
})}
|
||||
leftIcon={<signalBots.icon />}
|
||||
onClick={onMenuClick}
|
||||
sidebarIsOpen={open}
|
||||
dense={dense}
|
||||
/>
|
||||
<SubMenu
|
||||
handleToggle={() => handleToggle("menuVoice")}
|
||||
isOpen={state.menuVoice}
|
||||
sidebarIsOpen={open}
|
||||
name="pos.menu.voice"
|
||||
icon={<VoiceIcon />}
|
||||
dense={dense}
|
||||
>
|
||||
<MenuItemLink
|
||||
to={`/voiceproviders`}
|
||||
primaryText={translate(`resources.providers.name`, {
|
||||
smart_count: 2,
|
||||
})}
|
||||
leftIcon={<voiceProviders.icon />}
|
||||
onClick={onMenuClick}
|
||||
sidebarIsOpen={open}
|
||||
dense={dense}
|
||||
/>
|
||||
<MenuItemLink
|
||||
to={`/voicelines`}
|
||||
primaryText={translate(`resources.voicelines.name`, {
|
||||
smart_count: 2,
|
||||
})}
|
||||
leftIcon={<voiceLines.icon />}
|
||||
onClick={onMenuClick}
|
||||
sidebarIsOpen={open}
|
||||
dense={dense}
|
||||
/>
|
||||
</SubMenu>
|
||||
<MenuItemLink
|
||||
to={`/webhooks`}
|
||||
primaryText={translate(`resources.webhooks.name`, {
|
||||
smart_count: 2,
|
||||
})}
|
||||
leftIcon={<webhooks.icon />}
|
||||
onClick={onMenuClick}
|
||||
sidebarIsOpen={open}
|
||||
dense={dense}
|
||||
/>
|
||||
{isXSmall && logout}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import { FC, PropsWithChildren, Fragment, ReactElement } from "react";
|
||||
import ExpandMore from "@mui/icons-material/ExpandMore";
|
||||
import List from "@mui/material/List";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
// import { makeStyles } from "@mui/styles";
|
||||
import { useTranslate } from "react-admin";
|
||||
|
||||
/*
|
||||
const useStyles = makeStyles((theme: any) => ({
|
||||
icon: { minWidth: theme.spacing(5) },
|
||||
sidebarIsOpen: {
|
||||
"& a": {
|
||||
paddingLeft: theme.spacing(4),
|
||||
transition: "padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms",
|
||||
},
|
||||
},
|
||||
sidebarIsClosed: {
|
||||
"& a": {
|
||||
paddingLeft: theme.spacing(2),
|
||||
transition: "padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms",
|
||||
},
|
||||
},
|
||||
}));
|
||||
*/
|
||||
type SubMenuProps = PropsWithChildren<{
|
||||
dense: boolean;
|
||||
handleToggle: () => void;
|
||||
icon: ReactElement;
|
||||
isOpen: boolean;
|
||||
name: string;
|
||||
sidebarIsOpen: boolean;
|
||||
}>;
|
||||
|
||||
export const SubMenu: FC<SubMenuProps> = ({
|
||||
handleToggle,
|
||||
sidebarIsOpen,
|
||||
isOpen,
|
||||
name,
|
||||
icon,
|
||||
children,
|
||||
dense,
|
||||
}: any) => {
|
||||
const translate = useTranslate();
|
||||
const classes: any = {}; // = useStyles();
|
||||
|
||||
const header = (
|
||||
// @ts-ignore
|
||||
<MenuItem dense={dense} button onClick={handleToggle}>
|
||||
<ListItemIcon className={classes.icon}>
|
||||
{isOpen ? <ExpandMore /> : icon}
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit" color="textSecondary">
|
||||
{translate(name)}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{sidebarIsOpen || isOpen ? (
|
||||
header
|
||||
) : (
|
||||
<Tooltip title={translate(name)} placement="right">
|
||||
{header}
|
||||
</Tooltip>
|
||||
)}
|
||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||
<List
|
||||
dense={dense}
|
||||
component="div"
|
||||
disablePadding
|
||||
className={
|
||||
sidebarIsOpen ? classes.sidebarIsOpen : classes.sidebarIsClosed
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
"use client";
|
||||
|
||||
export { default as AppBar } from "./AppBar";
|
||||
export { Layout } from "./Layout";
|
||||
export { default as Menu } from "./Menu";
|
||||
103
apps/link/app/(main)/admin/metamigo/_components/layout/themes.ts
Normal file
103
apps/link/app/(main)/admin/metamigo/_components/layout/themes.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
export const theme = {
|
||||
spacing: () => 8,
|
||||
palette: {
|
||||
primary: {
|
||||
main: "#337799",
|
||||
},
|
||||
secondary: {
|
||||
light: "#5f5fc4",
|
||||
main: "#283593",
|
||||
dark: "#001064",
|
||||
contrastText: "#fff",
|
||||
},
|
||||
background: {
|
||||
default: "#fff",
|
||||
},
|
||||
getContrastText(color: string) {
|
||||
return color === "#ffffff" ? "#000" : "#fff";
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 5,
|
||||
},
|
||||
breakpoints: {
|
||||
up: (key: any) => `@media (min-width:${key})`,
|
||||
down: (key: any) => `@media (max-width:${key})`,
|
||||
},
|
||||
transitions: {
|
||||
create(props: any) {
|
||||
return `all ${props.duration}ms ${props.easing}`;
|
||||
},
|
||||
easing: {
|
||||
easeInOut: "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
easeOut: "cubic-bezier(0.0, 0, 0.2, 1)",
|
||||
easeIn: "cubic-bezier(0.4, 0, 1, 1)",
|
||||
sharp: "cubic-bezier(0.4, 0, 0.6, 1)",
|
||||
},
|
||||
duration: {
|
||||
shortest: 150,
|
||||
shorter: 200,
|
||||
short: 250,
|
||||
standard: 300,
|
||||
complex: 375,
|
||||
enteringScreen: 225,
|
||||
leavingScreen: 195,
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "Poppins, sans-serif",
|
||||
fontWeight: 900,
|
||||
h6: { fontSize: 16, fontWeight: 600, color: "red" },
|
||||
},
|
||||
/*
|
||||
overrides: {
|
||||
RaMenuItemLink: {
|
||||
root: {
|
||||
borderLeft: "3px solid #fff",
|
||||
},
|
||||
active: {
|
||||
borderLeft: "3px solid #ef7706",
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
elevation1: {
|
||||
boxShadow: "none",
|
||||
},
|
||||
root: {
|
||||
border: "1px solid #e0e0e3",
|
||||
backgroundClip: "padding-box",
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
contained: {
|
||||
backgroundColor: "#fff",
|
||||
color: "#4f3cc9",
|
||||
boxShadow: "none",
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
colorSecondary: {
|
||||
color: "#fff",
|
||||
backgroundColor: "#337799",
|
||||
border: 0,
|
||||
},
|
||||
},
|
||||
MuiLinearProgress: {
|
||||
colorPrimary: {
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
barColorPrimary: {
|
||||
backgroundColor: "#d7d7d7",
|
||||
},
|
||||
},
|
||||
MuiFilledInput: {
|
||||
root: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.04)",
|
||||
"&$disabled": {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.04)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
*/
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
.input {
|
||||
width: 40px;
|
||||
height: 60px;
|
||||
margin: 5px;
|
||||
font-size: 1.4rem;
|
||||
padding: 0 9px 0 12px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: white;
|
||||
font-weight: 400;
|
||||
color: rgba(59, 59, 59, 0.788);
|
||||
-webkit-box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6;
|
||||
box-shadow: 2px 2px 2px 1px #d6d6d6, -1px -1px 1px #e6e6e6;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.hyphen {
|
||||
background: black;
|
||||
height: 0.1em;
|
||||
width: 1em;
|
||||
margin: 0 0.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import {
|
||||
SimpleForm,
|
||||
Create,
|
||||
TextInput,
|
||||
required,
|
||||
CreateProps,
|
||||
} from "react-admin";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { validateE164Number } from "../../../_lib/phone-numbers";
|
||||
|
||||
const SignalBotCreate: FC<CreateProps> = (props) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<Create {...props} title="Create Signal Bot">
|
||||
<SimpleForm>
|
||||
<TextInput
|
||||
source="userId"
|
||||
defaultValue={
|
||||
// @ts-expect-error: ID does exist
|
||||
session.user.id
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
source="phoneNumber"
|
||||
validate={[validateE164Number, required()]}
|
||||
/>
|
||||
<TextInput source="description" />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignalBotCreate;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { SimpleForm, Edit, TextInput, required, EditProps } from "react-admin";
|
||||
|
||||
const SignalBotEdit: FC<EditProps> = (props) => (
|
||||
<Edit {...props} title="Edit Bot">
|
||||
<SimpleForm>
|
||||
<TextInput disabled source="phoneNumber" validate={[required()]} />
|
||||
<TextInput source="description" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
|
||||
export default SignalBotEdit;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import {
|
||||
List,
|
||||
Datagrid,
|
||||
DateField,
|
||||
TextField,
|
||||
BooleanField,
|
||||
ListProps,
|
||||
} from "react-admin";
|
||||
|
||||
const SignalBotList: FC<ListProps> = (props) => (
|
||||
<List {...props} exporter={false}>
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="phoneNumber" />
|
||||
<TextField source="description" />
|
||||
<BooleanField source="isVerified" />
|
||||
<DateField source="createdAt" />
|
||||
<DateField source="updatedAt" />
|
||||
<TextField source="createdBy" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export default SignalBotList;
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import {
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
BooleanField,
|
||||
TextField,
|
||||
ShowProps,
|
||||
EditButton,
|
||||
TopToolbar,
|
||||
useTranslate,
|
||||
useRefresh,
|
||||
} from "react-admin";
|
||||
import {
|
||||
TextField as MuiTextField,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import { SixDigitInput } from "../../DigitInput";
|
||||
import {
|
||||
sanitizeE164Number,
|
||||
isValidE164Number,
|
||||
} from "../../../_lib/phone-numbers";
|
||||
|
||||
const Sidebar = ({ record }: any) => {
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [errorNumber, setErrorNumber] = useState(false);
|
||||
const handlePhoneNumberChange = (event: any) => {
|
||||
setPhoneNumber(event.target.value);
|
||||
};
|
||||
|
||||
const [message, setMessage] = useState("");
|
||||
const handleMessageChange = (event: any) => {
|
||||
setMessage(event.target.value);
|
||||
};
|
||||
|
||||
const sendMessage = async (phoneNumber: string, message: string) => {
|
||||
await fetch(`/api/v1/signal/bots/${record.token}/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ phoneNumber, message }),
|
||||
});
|
||||
};
|
||||
|
||||
const resetSession = async (phoneNumber: string) => {
|
||||
await fetch(`/api/v1/signal/bots/${record.token}/resetSession`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ phoneNumber }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleBlurNumber = () => {
|
||||
setErrorNumber(!isValidE164Number(sanitizeE164Number(phoneNumber)));
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
const sanitized = sanitizeE164Number(phoneNumber);
|
||||
if (isValidE164Number(sanitized)) {
|
||||
setErrorNumber(false);
|
||||
sendMessage(sanitized, message);
|
||||
} else setErrorNumber(false);
|
||||
};
|
||||
|
||||
const handleResetSession = () => {
|
||||
const sanitized = sanitizeE164Number(phoneNumber);
|
||||
if (isValidE164Number(sanitized)) {
|
||||
setErrorNumber(false);
|
||||
resetSession(sanitized);
|
||||
} else setErrorNumber(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ width: "33%", marginLeft: 20, padding: 14 }}>
|
||||
<Grid container direction="column" spacing={2}>
|
||||
<Grid item>
|
||||
<Typography variant="h6">Send message</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<MuiTextField
|
||||
variant="outlined"
|
||||
label="Phone number"
|
||||
fullWidth
|
||||
size="small"
|
||||
error={errorNumber}
|
||||
onBlur={handleBlurNumber}
|
||||
value={phoneNumber}
|
||||
onChange={handlePhoneNumberChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<MuiTextField
|
||||
variant="outlined"
|
||||
label="Message"
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
size="small"
|
||||
value={message}
|
||||
onChange={handleMessageChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item container direction="row-reverse">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => handleSend()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => handleResetSession()}>
|
||||
Reset Session
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const MODE = {
|
||||
SMS: "SMS",
|
||||
VOICE: "VOICE",
|
||||
};
|
||||
|
||||
const handleRequestCode = async ({
|
||||
verifyMode,
|
||||
id,
|
||||
onSuccess,
|
||||
onError,
|
||||
captchaCode = undefined,
|
||||
}: any) => {
|
||||
if (verifyMode === MODE.SMS) console.log("REQUESTING sms");
|
||||
else if (verifyMode === MODE.VOICE) console.log("REQUESTING voice");
|
||||
let response: Response;
|
||||
let url = `/api/v1/signal/bots/${id}/requestCode?mode=${verifyMode.toLowerCase()}`;
|
||||
if (captchaCode) {
|
||||
url += `&captcha=${captchaCode}`;
|
||||
}
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response && response.ok) {
|
||||
onSuccess();
|
||||
} else {
|
||||
onError(response.status || 400);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to request verification code:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const VerificationCodeRequest = ({
|
||||
verifyMode,
|
||||
data,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: any) => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await handleRequestCode({
|
||||
verifyMode,
|
||||
id: data.id,
|
||||
onSuccess,
|
||||
onError,
|
||||
});
|
||||
})();
|
||||
}, [data.id, onError, onSuccess, verifyMode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
Requesting code for {data.phoneNumber}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex">
|
||||
<Box m="auto">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const VerificationCaptcha = ({
|
||||
verifyMode,
|
||||
data,
|
||||
onSuccess,
|
||||
onError,
|
||||
handleClose,
|
||||
}: any) => {
|
||||
const [code, setCode] = useState(undefined);
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmitVerification = async () => {
|
||||
setSubmitting(true);
|
||||
await handleRequestCode({
|
||||
verifyMode,
|
||||
id: data.id,
|
||||
onSuccess,
|
||||
onError,
|
||||
captchaCode: code,
|
||||
});
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleCaptchaChange = (value: any) => {
|
||||
if (value)
|
||||
setCode(
|
||||
value
|
||||
.replace(/signalcaptcha:\/\//, "")
|
||||
.replace("“", "")
|
||||
.replace("”", "")
|
||||
.trim(),
|
||||
);
|
||||
else setCode(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
Captcha for {data.phoneNumber}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<MuiTextField
|
||||
value={code}
|
||||
onChange={(ev) => handleCaptchaChange(ev.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{isSubmitting && <CircularProgress />}
|
||||
{!isSubmitting && (
|
||||
<Button onClick={handleClose} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{!isSubmitting && (
|
||||
<Button onClick={handleSubmitVerification} color="primary">
|
||||
Request
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const VerificationCodeInput = ({
|
||||
data,
|
||||
verifyMode,
|
||||
handleClose,
|
||||
handleRestartVerification,
|
||||
confirmVerification,
|
||||
}: any) => {
|
||||
const [code, setValue] = useState("");
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [isValid, setValid] = useState(false);
|
||||
const [submissionError, setSubmissionError] = useState(undefined);
|
||||
const translate = useTranslate();
|
||||
|
||||
const validator = (v: any) => v.trim().length === 6;
|
||||
|
||||
const handleValueChange = (newValue: any) => {
|
||||
setValue(newValue);
|
||||
setValid(validator(newValue));
|
||||
};
|
||||
|
||||
const handleSubmitVerification = async () => {
|
||||
setSubmitting(true);
|
||||
// await sleep(2000)
|
||||
const response = await fetch(
|
||||
`/api/v1/signal/bots/${data.id}/register?code=${code}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
setSubmitting(false);
|
||||
const responseBody = await response.json();
|
||||
console.log(responseBody);
|
||||
if (response.status === 200) {
|
||||
confirmVerification();
|
||||
} else if (responseBody.message)
|
||||
setSubmissionError(`Error: ${responseBody.message}`);
|
||||
else {
|
||||
setSubmissionError(
|
||||
"There was an error, sorry about that. Please try again later or contact support.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const title =
|
||||
verifyMode === MODE.SMS
|
||||
? translate("resources.signalBots.verifyDialog.sms", {
|
||||
phoneNumber: data.phoneNumber,
|
||||
})
|
||||
: translate("resources.signalBots.verifyDialog.voice", {
|
||||
phoneNumber: data.phoneNumber,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
Verify {data.phoneNumber}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{title}</DialogContentText>
|
||||
<SixDigitInput value={code} onChange={handleValueChange} />
|
||||
{submissionError && (
|
||||
<Typography variant="body1" gutterBottom color="error">
|
||||
{submissionError}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{isSubmitting && <CircularProgress />}
|
||||
{!isSubmitting && (
|
||||
<Button onClick={handleClose} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{!isSubmitting && (
|
||||
<Button onClick={handleRestartVerification} color="primary">
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{!isSubmitting && (
|
||||
<Button
|
||||
onClick={handleSubmitVerification}
|
||||
color="primary"
|
||||
disabled={!isValid}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const VerificationCodeDialog = (props: any) => {
|
||||
const [stage, setStage] = useState("request");
|
||||
const onRequestSuccess = () => setStage("verify");
|
||||
const onRestartVerification = () => setStage("request");
|
||||
const handleClose = () => {
|
||||
setStage("request");
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const onError = (code: number) => {
|
||||
if (code === 402 || code === 500) {
|
||||
setStage("captcha");
|
||||
} else {
|
||||
setStage("request");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onClose={props.handleClose}
|
||||
aria-labelledby="form-dialog-title"
|
||||
>
|
||||
{props.open && stage === "request" && (
|
||||
<VerificationCodeRequest
|
||||
mode={props.verifyMode}
|
||||
onSuccess={onRequestSuccess}
|
||||
onError={onError}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
{props.open && stage === "verify" && (
|
||||
<VerificationCodeInput
|
||||
{...props}
|
||||
handleRestartVerification={onRestartVerification}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
{props.open && stage === "captcha" && (
|
||||
<VerificationCaptcha
|
||||
mode={props.verifyMode}
|
||||
onSuccess={onRequestSuccess}
|
||||
onError={onRestartVerification}
|
||||
handleClose={handleClose}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const SignalBotShowActions = ({ data }: any) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [verifyMode, setVerifyMode] = useState("");
|
||||
const refresh = useRefresh();
|
||||
|
||||
const handleOpenSMS = () => {
|
||||
setVerifyMode(MODE.SMS);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenVoice = () => {
|
||||
setVerifyMode(MODE.VOICE);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => setOpen(false);
|
||||
const confirmVerification = () => {
|
||||
setOpen(false);
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<TopToolbar>
|
||||
<EditButton record={data} />
|
||||
{data && !data.isVerified && (
|
||||
<Button onClick={handleOpenSMS} color="primary">
|
||||
Verify with SMS
|
||||
</Button>
|
||||
)}
|
||||
{data && !data.isVerified && (
|
||||
<Button onClick={handleOpenVoice} color="primary">
|
||||
Verify with Voice
|
||||
</Button>
|
||||
)}
|
||||
{data && !data.isVerified && (
|
||||
<VerificationCodeDialog
|
||||
data={data}
|
||||
verifyMode={verifyMode}
|
||||
handleClose={handleClose}
|
||||
open={open}
|
||||
confirmVerification={confirmVerification}
|
||||
/>
|
||||
)}
|
||||
</TopToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const SignalBotShow: FC<ShowProps> = (props) => (
|
||||
<Show
|
||||
actions={<SignalBotShowActions />}
|
||||
{...props}
|
||||
title="Signal Bot"
|
||||
aside={<Sidebar />}
|
||||
>
|
||||
<SimpleShowLayout>
|
||||
<TextField source="phoneNumber" />
|
||||
<BooleanField source="isVerified" />
|
||||
<TextField source="description" />
|
||||
<TextField source="token" />
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
);
|
||||
|
||||
export default SignalBotShow;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import SignalBotIcon from "@mui/icons-material/ChatOutlined";
|
||||
import SignalBotList from "./SignalBotList";
|
||||
import SignalBotEdit from "./SignalBotEdit";
|
||||
import SignalBotCreate from "./SignalBotCreate";
|
||||
import SignalBotShow from "./SignalBotShow";
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default {
|
||||
list: SignalBotList,
|
||||
create: SignalBotCreate,
|
||||
edit: SignalBotEdit,
|
||||
show: SignalBotShow,
|
||||
icon: SignalBotIcon,
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
/* eslint-disable react/display-name */
|
||||
import {
|
||||
SelectInput,
|
||||
required,
|
||||
ReferenceInput,
|
||||
ReferenceField,
|
||||
TextField,
|
||||
} from "react-admin";
|
||||
|
||||
export const SignalBotSelectInput =
|
||||
(source: string): FC =>
|
||||
() =>
|
||||
(
|
||||
<ReferenceInput
|
||||
label="Signal Bot"
|
||||
source={source}
|
||||
reference="signalBots"
|
||||
validate={[required()]}
|
||||
>
|
||||
<SelectInput optionText="phoneNumber" />
|
||||
</ReferenceInput>
|
||||
);
|
||||
|
||||
export const SignalBotField =
|
||||
(source: string): FC =>
|
||||
() =>
|
||||
(
|
||||
<ReferenceField label="Signal Bot" reference="signalBots" source={source}>
|
||||
<TextField source="phoneNumber" />
|
||||
</ReferenceField>
|
||||
);
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import {
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
BooleanInput,
|
||||
Create,
|
||||
CreateProps,
|
||||
} from "react-admin";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { UserRoleInput } from "./shared";
|
||||
|
||||
const UserCreate: FC<CreateProps> = () => {
|
||||
const { data: session } = useSession();
|
||||
return (
|
||||
<Create title="Create Users">
|
||||
<SimpleForm>
|
||||
<TextInput source="email" />
|
||||
<TextInput source="name" />
|
||||
<UserRoleInput session={session} initialValue="NONE" />
|
||||
<BooleanInput source="isActive" defaultValue={true} />
|
||||
<TextInput source="createdBy" defaultValue={session?.user?.name} />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserCreate;
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
// import { makeStyles } from "@mui/styles";
|
||||
import {
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
BooleanInput,
|
||||
DateInput,
|
||||
Edit,
|
||||
Toolbar,
|
||||
SaveButton,
|
||||
DeleteButton,
|
||||
useRedirect,
|
||||
useRecordContext,
|
||||
} from "react-admin";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { UserRoleInput } from "./shared";
|
||||
|
||||
/*
|
||||
const useStyles = makeStyles((_theme: any) => ({
|
||||
defaultToolbar: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
}));
|
||||
*/
|
||||
|
||||
const UserEditToolbar = (props: any) => {
|
||||
const classes: any = {}; // = useStyles();
|
||||
const redirect = useRedirect();
|
||||
const record = useRecordContext();
|
||||
const { session } = props;
|
||||
|
||||
const shouldDisableDelete =
|
||||
!session || !session.user || session.user.id === record.id;
|
||||
|
||||
return (
|
||||
<Toolbar className={classes.defaultToolbar}>
|
||||
<SaveButton
|
||||
label="save"
|
||||
mutationOptions={{ onSuccess: () => redirect("/users") }}
|
||||
/>
|
||||
<DeleteButton disabled={shouldDisableDelete} />
|
||||
</Toolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const UserTitle = ({ record }: { record?: any }) => {
|
||||
let title = "";
|
||||
if (record) title = record.name ?? record.email;
|
||||
return <span>User {title}</span>;
|
||||
};
|
||||
|
||||
const UserEdit = () => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<Edit title={<UserTitle />}>
|
||||
<SimpleForm toolbar={<UserEditToolbar session={session} />}>
|
||||
<TextInput disabled source="id" />
|
||||
<TextInput source="email" />
|
||||
<TextInput source="name" />
|
||||
<UserRoleInput session={session} />
|
||||
<DateInput source="emailVerified" />
|
||||
<BooleanInput source="isActive" />
|
||||
<TextInput source="createdBy" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserEdit;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import {
|
||||
List,
|
||||
Datagrid,
|
||||
ImageField,
|
||||
DateField,
|
||||
TextField,
|
||||
EmailField,
|
||||
BooleanField,
|
||||
} from "react-admin";
|
||||
|
||||
const UserList: FC = () => (
|
||||
<List exporter={false}>
|
||||
<Datagrid rowClick="edit">
|
||||
<EmailField source="email" />
|
||||
<DateField source="emailVerified" />
|
||||
<TextField source="name" />
|
||||
<ImageField source="avatar" />
|
||||
<TextField source="userRole" />
|
||||
<BooleanField source="isActive" />
|
||||
<DateField source="createdAt" />
|
||||
<DateField source="updatedAt" />
|
||||
<TextField source="createdBy" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export default UserList;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import UserIcon from "@mui/icons-material/People";
|
||||
import UserList from "./UserList";
|
||||
import UserEdit from "./UserEdit";
|
||||
import UserCreate from "./UserCreate";
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default {
|
||||
list: UserList,
|
||||
create: UserCreate,
|
||||
edit: UserEdit,
|
||||
icon: UserIcon,
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { SelectInput, useRecordContext } from "react-admin";
|
||||
|
||||
export const UserRoleInput = (props: any) => {
|
||||
const record = useRecordContext();
|
||||
return (
|
||||
<SelectInput
|
||||
source="userRole"
|
||||
choices={[
|
||||
{ id: "NONE", name: "None" },
|
||||
{ id: "USER", name: "User" },
|
||||
{ id: "ADMIN", name: "Admin" },
|
||||
]}
|
||||
disabled={props.session.user.id === record.id}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
Create,
|
||||
PasswordInput,
|
||||
CreateProps,
|
||||
} from "react-admin";
|
||||
import { ProviderKindInput } from "./shared";
|
||||
|
||||
// import TextField from "@mui/material/TextField";
|
||||
|
||||
/* const TwilioCredentialsInput = () => (
|
||||
<span>
|
||||
<TextField name="accountSid" label="Account Sid" />
|
||||
<TextField name="authToken" label="Auth Token" />
|
||||
</span>
|
||||
); */
|
||||
|
||||
const ProviderCreate = (props: CreateProps) => (
|
||||
<Create {...props} title="Create Providers">
|
||||
<SimpleForm>
|
||||
<ProviderKindInput />
|
||||
<TextInput source="name" />
|
||||
<TextInput source="credentials.accountSid" />
|
||||
<TextInput source="credentials.apiKeySid" />
|
||||
<PasswordInput source="credentials.apiKeySecret" />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
|
||||
export default ProviderCreate;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Edit,
|
||||
EditProps,
|
||||
} from "react-admin";
|
||||
import { ProviderKindInput } from "./shared";
|
||||
|
||||
const ProviderTitle = ({ record }: { record?: any }) => {
|
||||
let title = "";
|
||||
if (record) title = record.name ?? record.email;
|
||||
return <span>Provider {title}</span>;
|
||||
};
|
||||
|
||||
const ProviderEdit = (props: EditProps) => (
|
||||
<Edit title={<ProviderTitle />} {...props}>
|
||||
<SimpleForm>
|
||||
<TextInput disabled source="id" />
|
||||
<ProviderKindInput disabled />
|
||||
<TextInput source="name" />
|
||||
<TextInput source="credentials.accountSid" />
|
||||
<TextInput source="credentials.apiKeySid" />
|
||||
<PasswordInput source="credentials.apiKeySecret" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
|
||||
export default ProviderEdit;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { List, Datagrid, DateField, TextField, ListProps } from "react-admin";
|
||||
|
||||
const ProviderList = (props: ListProps) => (
|
||||
<List {...props} exporter={false}>
|
||||
<Datagrid rowClick="edit">
|
||||
<TextField source="kind" />
|
||||
<TextField source="name" />
|
||||
<DateField source="createdAt" />
|
||||
<DateField source="updatedAt" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export default ProviderList;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
/* eslint-disable import/no-anonymous-default-export */
|
||||
import ProviderIcon from "@mui/icons-material/Business";
|
||||
import ProviderList from "./ProviderList";
|
||||
import ProviderEdit from "./ProviderEdit";
|
||||
import ProviderCreate from "./ProviderCreate";
|
||||
|
||||
export default {
|
||||
list: ProviderList,
|
||||
create: ProviderCreate,
|
||||
edit: ProviderEdit,
|
||||
icon: ProviderIcon,
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { SelectInput } from "react-admin";
|
||||
|
||||
export const ProviderKindInput = (props: any) => (
|
||||
<SelectInput
|
||||
source="kind"
|
||||
choices={[{ id: "TWILIO", name: "Twilio" }]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
.voiceWaveWrapper {
|
||||
width: 100%;
|
||||
max-height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.visible {
|
||||
display: block;
|
||||
}
|
||||
.buttonWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playerWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.recordTime {
|
||||
align-self: center;
|
||||
width: 66px;
|
||||
height: 18px;
|
||||
margin-top: 10px;
|
||||
font-family: "sans";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
color: #000;
|
||||
}
|
||||
.content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import { useInput } from "react-admin";
|
||||
import React, { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import MicIcon from "@mui/icons-material/Mic";
|
||||
import StopIcon from "@mui/icons-material/Stop";
|
||||
import Button from "@mui/material/Button";
|
||||
// import { useTheme } from "@mui/styles"; // makeStyles,
|
||||
// import AudioPlayer from "material-ui-audio-player";
|
||||
import { useStopwatch } from "react-timer-hook";
|
||||
import style from "./MicInput.module.css";
|
||||
// import type { ReactMicProps } from "react-mic";
|
||||
|
||||
const ReactMic = dynamic<any>(
|
||||
() => {
|
||||
throw new Error(
|
||||
"MIC INPUT FEATURE IS DISABLED",
|
||||
); /* return import("react-mic").then((mod) => mod.ReactMic); */
|
||||
},
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const blobToDataUri = (blob: Blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
return new Promise((resolve) => {
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const dataUriToObj = (dataUri: string) => {
|
||||
const [prefix, base64] = dataUri.split(",");
|
||||
const mime = prefix.slice(5, prefix.indexOf(";"));
|
||||
|
||||
const result: any = {};
|
||||
result[mime] = base64;
|
||||
return result;
|
||||
};
|
||||
|
||||
const blobToResult = async (blob: Blob) => {
|
||||
const result = dataUriToObj((await blobToDataUri(blob)) as string);
|
||||
return result;
|
||||
};
|
||||
|
||||
/* const resultToDataUri = (result: Record<string, any>): string => {
|
||||
if (!result || !result["audio/webm"]) return "";
|
||||
const base64 = result["audio/webm"];
|
||||
const r = `data:audio/webm;base64,${base64}`;
|
||||
return r;
|
||||
}; */
|
||||
|
||||
const MicInput = (props: any) => {
|
||||
const { seconds, minutes, hours, start, reset, pause } = useStopwatch();
|
||||
const theme: any = {}; // useTheme();
|
||||
const {
|
||||
field: { onChange }, // value
|
||||
} = useInput(props);
|
||||
|
||||
const [record, setRecorder] = useState({ record: false });
|
||||
// const decodedValue = resultToDataUri(value);
|
||||
const startRecording = () => {
|
||||
setRecorder({ record: true });
|
||||
reset();
|
||||
start();
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
setRecorder({ record: false });
|
||||
pause();
|
||||
};
|
||||
|
||||
async function onData(recordedBlob: any) {
|
||||
console.log({ recordedBlob });
|
||||
}
|
||||
|
||||
async function onStop(recordedBlob: any) {
|
||||
const result = await blobToResult(recordedBlob.blob);
|
||||
onChange(result);
|
||||
}
|
||||
|
||||
const isRecording = record.record;
|
||||
// const canPlay = !isRecording && decodedValue;
|
||||
const duration = `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
|
||||
/*
|
||||
const useStyles = makeStyles(() => ({
|
||||
volumeIcon: {
|
||||
display: "none",
|
||||
},
|
||||
mainSlider: {
|
||||
display: "none",
|
||||
},
|
||||
}));
|
||||
*/
|
||||
return (
|
||||
<div className="MuiFormControl-marginDense RaFormInput-input-40">
|
||||
<div className={style.content}>
|
||||
<div className={style.voiceWaveWrapper}>
|
||||
<ReactMic
|
||||
record={record.record}
|
||||
className={isRecording ? style.visible : style.hidden}
|
||||
onStop={onStop}
|
||||
onData={onData}
|
||||
// @ts-ignore
|
||||
strokeColor={theme.palette.primary.main}
|
||||
backgroundColor="white"
|
||||
mimeType="audio/webm"
|
||||
visualSetting="frequencyBars"
|
||||
/>
|
||||
</div>
|
||||
<div>{isRecording ? <p>Recording... {duration}</p> : ""}</div>
|
||||
|
||||
<div className={style.buttonWrapper}>
|
||||
{isRecording ? (
|
||||
<Button variant="contained" color="primary" onClick={stopRecording}>
|
||||
<StopIcon />
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={startRecording}
|
||||
style={{ marginRight: "20px" }}
|
||||
>
|
||||
<MicIcon />
|
||||
Record
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.playerWrapper}>
|
||||
{/* canPlay && (
|
||||
<AudioPlayer
|
||||
elevation={0}
|
||||
src={decodedValue}
|
||||
variation="secondary"
|
||||
volume={false}
|
||||
useStyles={useStyles}
|
||||
/>
|
||||
) */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MicInput;
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
SimpleForm,
|
||||
Create,
|
||||
FormDataConsumer,
|
||||
SelectInput,
|
||||
BooleanInput,
|
||||
ReferenceInput,
|
||||
required,
|
||||
CreateProps,
|
||||
} from "react-admin";
|
||||
import TwilioLanguages from "./twilio-languages";
|
||||
import {
|
||||
PromptInput,
|
||||
VoiceInput,
|
||||
AvailableNumbersInput,
|
||||
populateNumber,
|
||||
} from "./shared";
|
||||
import MicInput from "./MicInput";
|
||||
|
||||
const VoiceLineCreate = (props: CreateProps) => (
|
||||
<Create {...props} title="Create Voice Line" transform={populateNumber}>
|
||||
<SimpleForm>
|
||||
<ReferenceInput
|
||||
label="Provider"
|
||||
source="providerId"
|
||||
reference="voiceProviders"
|
||||
validate={[required()]}
|
||||
>
|
||||
<SelectInput optionText={(p) => `${p.kind}: ${p.name}`} />
|
||||
</ReferenceInput>
|
||||
<FormDataConsumer subscription={{ values: true }}>
|
||||
{AvailableNumbersInput}
|
||||
</FormDataConsumer>
|
||||
<SelectInput
|
||||
source="language"
|
||||
choices={TwilioLanguages.languages}
|
||||
validate={[required()]}
|
||||
/>
|
||||
<FormDataConsumer subscription={{ values: true }}>
|
||||
{VoiceInput}
|
||||
</FormDataConsumer>
|
||||
|
||||
<FormDataConsumer subscription={{ values: true }}>
|
||||
{PromptInput}
|
||||
</FormDataConsumer>
|
||||
<BooleanInput source="audioPromptEnabled" />
|
||||
<MicInput source="promptAudio" />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
|
||||
export default VoiceLineCreate;
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
Edit,
|
||||
FormDataConsumer,
|
||||
SelectInput,
|
||||
BooleanInput,
|
||||
ReferenceInput,
|
||||
required,
|
||||
EditProps,
|
||||
} from "react-admin";
|
||||
import TwilioLanguages from "./twilio-languages";
|
||||
import { VoiceInput, PromptInput } from "./shared";
|
||||
import MicInput from "./MicInput";
|
||||
|
||||
const VoiceLineTitle = ({ record }: { record?: any }) => {
|
||||
let title = "";
|
||||
if (record) title = record.name ?? record.email;
|
||||
return <span>VoiceLine {title}</span>;
|
||||
};
|
||||
|
||||
const VoiceLineEdit = (props: EditProps) => (
|
||||
<Edit title={<VoiceLineTitle />} {...props}>
|
||||
<SimpleForm>
|
||||
<ReferenceInput
|
||||
disabled
|
||||
label="Provider"
|
||||
source="providerId"
|
||||
reference="providers"
|
||||
validate={[required()]}
|
||||
>
|
||||
<SelectInput optionText={(p) => `${p.kind}: ${p.name}`} />
|
||||
</ReferenceInput>
|
||||
<TextInput disabled source="providerLineSid" />
|
||||
<TextInput disabled source="number" />
|
||||
<SelectInput source="language" choices={TwilioLanguages.languages} />
|
||||
<FormDataConsumer subscription={{ values: true }}>
|
||||
{VoiceInput}
|
||||
</FormDataConsumer>
|
||||
<FormDataConsumer subscription={{ values: true }}>
|
||||
{PromptInput}
|
||||
</FormDataConsumer>
|
||||
<BooleanInput source="audioPromptEnabled" />
|
||||
<MicInput source="promptAudio" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
|
||||
export default VoiceLineEdit;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
List,
|
||||
ListProps,
|
||||
Datagrid,
|
||||
DateField,
|
||||
FunctionField,
|
||||
TextField,
|
||||
ReferenceField,
|
||||
} from "react-admin";
|
||||
|
||||
const VoiceLineList = (props: ListProps) => (
|
||||
<List {...props} exporter={false}>
|
||||
<Datagrid rowClick="edit">
|
||||
<ReferenceField
|
||||
label="Provider"
|
||||
source="providerId"
|
||||
reference="providers"
|
||||
>
|
||||
<FunctionField render={(p: any) => `${p.kind}: ${p.name}`} />
|
||||
</ReferenceField>
|
||||
<TextField source="number" />
|
||||
<TextField source="language" />
|
||||
<TextField source="voice" />
|
||||
<DateField source="createdAt" />
|
||||
<DateField source="updatedAt" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export default VoiceLineList;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import VoiceLineIcon from "@mui/icons-material/PhoneCallback";
|
||||
import VoiceLineList from "./VoiceLineList";
|
||||
import VoiceLineEdit from "./VoiceLineEdit";
|
||||
import VoiceLineCreate from "./VoiceLineCreate";
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default {
|
||||
list: VoiceLineList,
|
||||
create: VoiceLineCreate,
|
||||
edit: VoiceLineEdit,
|
||||
icon: VoiceLineIcon,
|
||||
};
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
/* add css module styles here (optional) */
|
||||
@import url("https://fonts.googleapis.com/css?family=Lato:400,700&display=swap");
|
||||
.recorder_library_box,
|
||||
.recorder_library_box * {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: "Lato", sans-serif;
|
||||
}
|
||||
.recorder_library_box .recorder_box {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 30px 0;
|
||||
}
|
||||
.recorder_library_box .recorder_box_inner {
|
||||
min-height: 400px;
|
||||
background: #212121;
|
||||
border-radius: 0 0 3px 3px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.recorder_library_box .mic_icon {
|
||||
width: 60px;
|
||||
display: flex;
|
||||
height: 60px;
|
||||
position: fixed;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgb(245, 0, 87);
|
||||
border-radius: 50%;
|
||||
bottom: 65px;
|
||||
right: 20%;
|
||||
color: #fff;
|
||||
font-size: 25px;
|
||||
}
|
||||
.recorder_library_box .reco_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: #bd9f61;
|
||||
align-items: center;
|
||||
padding: 20px 20px;
|
||||
color: #fff;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
.recorder_library_box .reco_header .h2 {
|
||||
font-weight: 400;
|
||||
}
|
||||
.recorder_library_box .reco_header .close_icons {
|
||||
font-size: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
transition: 0.5s ease all;
|
||||
}
|
||||
.recorder_library_box .reco_header .close_icons:hover {
|
||||
background: rgba(123, 118, 106, 0.21);
|
||||
}
|
||||
|
||||
.recorder_library_box .record_section {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.recorder_library_box .record_section .mic_icon {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 20px;
|
||||
}
|
||||
.recorder_library_box .record_section .duration_section {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%);
|
||||
bottom: 100px;
|
||||
}
|
||||
|
||||
.recorder_library_box .btn_wrapper {
|
||||
margin: 20px 30px;
|
||||
}
|
||||
.recorder_library_box .btn_wrapper .btn {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
background: #185fec;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
border: 1px solid #185fec;
|
||||
transition: 0.3s ease all;
|
||||
}
|
||||
.recorder_library_box .btn_wrapper .btn:hover {
|
||||
background: #fff;
|
||||
color: #185fec;
|
||||
}
|
||||
.recorder_library_box .btn_wrapper .clear_btn {
|
||||
background: #fff;
|
||||
color: #185fec;
|
||||
margin-left: 15px;
|
||||
}
|
||||
.recorder_library_box .btn_wrapper .clear_btn:hover {
|
||||
background: #185fec;
|
||||
color: #fff;
|
||||
}
|
||||
.recorder_library_box .duration {
|
||||
text-align: center;
|
||||
}
|
||||
.recorder_library_box .recorder_page_box {
|
||||
min-height: calc(100vh - 128px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.recorder_library_box .duration * {
|
||||
color: #fff;
|
||||
font-size: 60px;
|
||||
}
|
||||
.recorder_library_box .duration_section .help {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.recorder_library_box .record_controller {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%);
|
||||
bottom: 0px;
|
||||
padding: 20px 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.recorder_library_box .record_controller .icons {
|
||||
width: 50px;
|
||||
display: flex;
|
||||
height: 50px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
margin-right: 15px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.recorder_library_box .record_controller .stop {
|
||||
background: #940505;
|
||||
}
|
||||
.recorder_library_box .record_controller .pause {
|
||||
background: #9c6702;
|
||||
}
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
"use client";
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
import React, { useState, useEffect } from "react";
|
||||
import PlayIcon from "@mui/icons-material/PlayCircleFilled";
|
||||
import {
|
||||
TextInput,
|
||||
SelectInput,
|
||||
required,
|
||||
useTranslate,
|
||||
useNotify,
|
||||
ReferenceInput,
|
||||
ReferenceField,
|
||||
TextField,
|
||||
} from "react-admin";
|
||||
import { IconButton, CircularProgress } from "@mui/material";
|
||||
import absoluteUrl from "../../../_lib/absolute-url";
|
||||
import TwilioLanguages from "./twilio-languages";
|
||||
|
||||
type TTSProvider = (voice: any, language: any, prompt: any) => Promise<void>;
|
||||
|
||||
const tts = async (providerId: any): Promise<TTSProvider> => {
|
||||
const r = await fetch(
|
||||
`/api/v1/voice/twilio/text-to-speech-token/${providerId}`,
|
||||
);
|
||||
const { token } = await r.json();
|
||||
const twilioClient = await import("twilio-client");
|
||||
return (voice, language, prompt): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (!voice || !language || !prompt) resolve();
|
||||
const { Device } = twilioClient;
|
||||
const device = new Device();
|
||||
const silence = `${absoluteUrl().origin}/static/silence.mp3`;
|
||||
device.setup(token, {
|
||||
codecPrefences: ["opus", "pcmu"],
|
||||
enableRingingState: false,
|
||||
fakeLocalDTMF: true,
|
||||
disableAudioContextSounds: true,
|
||||
sounds: {
|
||||
disconnect: silence,
|
||||
incoming: silence,
|
||||
outgoing: silence,
|
||||
},
|
||||
});
|
||||
device.on("ready", (device: any) => {
|
||||
device.connect({ language, voice, prompt });
|
||||
});
|
||||
device.on("disconnect", () => resolve());
|
||||
device.on("error", () => resolve());
|
||||
});
|
||||
};
|
||||
|
||||
export const TextToSpeechButton = ({ form }: any) => {
|
||||
const { providerId, language, voice, promptText: prompt } = form.formData;
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [ttsProvider, setTTSProvider] = useState<
|
||||
undefined | { provider: TTSProvider }
|
||||
>(undefined);
|
||||
const [playText, setPlayText] = useState<
|
||||
undefined | { func: () => Promise<void> }
|
||||
>(undefined);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (providerId) {
|
||||
setLoading(true);
|
||||
setTTSProvider({ provider: await tts(providerId) });
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setPlayText({
|
||||
async func() {
|
||||
setLoading(true);
|
||||
if (ttsProvider) await ttsProvider.provider(voice, language, prompt);
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
})();
|
||||
}, [prompt, language, voice, ttsProvider, ttsProvider?.provider]);
|
||||
|
||||
const disabled = !(providerId && prompt?.length >= 2 && voice && language);
|
||||
/* TODO add this back to IconButtonwhen we know how to extend MUI theme and appease typescript
|
||||
variant="contained"
|
||||
*/
|
||||
return (
|
||||
<IconButton onClick={playText?.func} disabled={disabled} color="primary">
|
||||
{!loading && <PlayIcon />}
|
||||
{loading && <CircularProgress size={20} />}
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const PromptInput = (form: any, ...rest: any[]) => (
|
||||
<TextInput
|
||||
source="promptText"
|
||||
multiline
|
||||
// options={{ fullWidth: true }}
|
||||
InputProps={{ endAdornment: <TextToSpeechButton form={form} /> }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
const validateVoice = (_args: any, values: any) => {
|
||||
if (!values.language) return "validation.language";
|
||||
if (!values.voice) return "validation.voice";
|
||||
const availableVoices = TwilioLanguages.voices[values.language];
|
||||
const found =
|
||||
availableVoices.filter((v: any) => v.id === values.voice).length === 1;
|
||||
if (!found) return "validation.voice";
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const VoiceInput = (form: any, ...rest: any[]) => {
|
||||
const voice = TwilioLanguages.voices[form.formData.language] || [];
|
||||
return (
|
||||
// @ts-expect-error
|
||||
<SelectInput
|
||||
source="voice"
|
||||
choices={voice}
|
||||
validate={[required(), validateVoice]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
let noAvailableNumbers = false;
|
||||
let availableNumbers: any[] = [];
|
||||
|
||||
const getAvailableNumbers = async (providerId: string) => {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/voice/providers/${providerId}/freeNumbers`);
|
||||
availableNumbers = await r.json();
|
||||
noAvailableNumbers = availableNumbers.length === 0;
|
||||
return availableNumbers;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Could not fetch available numbers for provider ${providerId} - ${error}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const sidToNumber = (sid: any) =>
|
||||
availableNumbers.filter(({ id }) => id === sid).map(({ name }) => name)[0];
|
||||
|
||||
export const populateNumber = (data: any) => ({
|
||||
...data,
|
||||
number: sidToNumber(data.providerLineSid),
|
||||
});
|
||||
|
||||
const hasNumbers = (
|
||||
_args: any,
|
||||
_value: any,
|
||||
_values: any,
|
||||
_translate: any,
|
||||
..._props: any[]
|
||||
) => {
|
||||
if (noAvailableNumbers) return "validation.noAvailableNumbers";
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const AvailableNumbersInput = (form: any, ...rest: any[]) => {
|
||||
const {
|
||||
// @ts-expect-error: non-existent property
|
||||
meta: { touched, error } = {},
|
||||
// @ts-expect-error: non-existent property
|
||||
input: { ...inputProps },
|
||||
...props
|
||||
} = rest;
|
||||
const translate = useTranslate();
|
||||
const notify = useNotify();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [choices, setChoices] = useState({});
|
||||
// @ts-expect-error: Invalid return type
|
||||
useEffect(async () => {
|
||||
if (form && form.formData && form.formData.providerId) {
|
||||
setLoading(true);
|
||||
const choices = await getAvailableNumbers(form.formData.providerId);
|
||||
setChoices({
|
||||
choices,
|
||||
helperText: noAvailableNumbers
|
||||
? translate("validation.noAvailableNumbers")
|
||||
: "",
|
||||
});
|
||||
if (noAvailableNumbers)
|
||||
notify("validation.noAvailableNumbers", { type: "error" });
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form, notify, translate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectInput
|
||||
label="Number"
|
||||
source="providerLineSid"
|
||||
// @ts-expect-error: non-existent property
|
||||
choices={choices.choices}
|
||||
disabled={loading}
|
||||
validate={[hasNumbers, required()]}
|
||||
// @ts-expect-error: non-existent property
|
||||
error={Boolean(touched && error) || Boolean(choices.helperText)}
|
||||
// @ts-expect-error: non-existent property
|
||||
helperText={choices.helperText}
|
||||
{...inputProps}
|
||||
{...props}
|
||||
/>
|
||||
{loading && <CircularProgress />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
const voiceLineName = voiceLine => {
|
||||
return voiceLine.number
|
||||
}
|
||||
const getVoiceLineChoices = async ():Promise<any[]> => {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/voice/voice-line`);
|
||||
const lines = await r.json();
|
||||
if(lines.data?.length > 0) {
|
||||
return lines.data.map(voiceLine => ({"id": voiceLine.id, "name": voiceLineName(voiceLine)}))
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Could not fetch voice lines error: ${error}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const AsyncSelectInput = (choiceLoader: () => Promise<any[]>, label, source, translationEmpty,) => (form, ...rest) => {
|
||||
const {
|
||||
meta: { touched, error } = {},
|
||||
input: { ...inputProps },
|
||||
...props
|
||||
} = rest;
|
||||
|
||||
const translate = useTranslate();
|
||||
const notify = useNotify();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [choices, setChoices] = useState({choices: []});
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
//const items = await choiceLoader()
|
||||
const items = [{"id": "testing", "name": "OMG"}]
|
||||
setChoices({
|
||||
choices: items,
|
||||
helperText: items.length === 0
|
||||
? translate(translationEmpty)
|
||||
: "",
|
||||
});
|
||||
if (items.length === 0) notify(translationEmpty, "error");
|
||||
setLoading(false);
|
||||
})()}, [form && form.formData ? form.formData.providerId : undefined]);
|
||||
|
||||
const isNotEmpty = () => {
|
||||
if (choices.choices.length === 0) return translationEmpty;
|
||||
return undefined;
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{choices.choices.length > 0 &&
|
||||
<SelectInput
|
||||
label={label}
|
||||
source={source}
|
||||
choices={choices.choices}
|
||||
disabled={loading}
|
||||
validate={[isNotEmpty, required()]}
|
||||
error={Boolean(touched && error) || Boolean(choices.helperText)}
|
||||
helperText={choices.helperText}
|
||||
{...inputProps}
|
||||
{...props}
|
||||
/>}
|
||||
{loading && <CircularProgress />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const VoiceLineSelectInput = AsyncSelectInput(getVoiceLineChoices, "Voice Line", "backendId", "validation.noVoiceLines" )
|
||||
*/
|
||||
|
||||
export const VoiceLineSelectInput = (source: string) => () => (
|
||||
<ReferenceInput
|
||||
label="Voice Line"
|
||||
source={source}
|
||||
reference="voiceLines"
|
||||
validate={[required()]}
|
||||
>
|
||||
<SelectInput optionText="number" />
|
||||
</ReferenceInput>
|
||||
);
|
||||
|
||||
export const VoiceLineField = (source: string) => () => (
|
||||
<ReferenceField label="Voice Line" source={source} reference="voiceLines">
|
||||
<TextField source="number" />
|
||||
</ReferenceField>
|
||||
);
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
const languages = {
|
||||
languages: [
|
||||
{ id: "arb", name: "Arabic" },
|
||||
{ id: "cy-GB", name: "Welsh" },
|
||||
{ id: "da-DK", name: "Danish" },
|
||||
{ id: "de-DE", name: "German" },
|
||||
{ id: "en-US", name: "English (US)" },
|
||||
{ id: "en-AU", name: "English (Australian)" },
|
||||
{ id: "en-GB", name: "English (British)" },
|
||||
{ id: "en-GB-WLS", name: "English (Welsh)" },
|
||||
{ id: "en-IN", name: "English (Indian)" },
|
||||
{ id: "es-ES", name: "Spanish (Castilian)" },
|
||||
{ id: "es-MX", name: "Spanish (Mexico)" },
|
||||
{ id: "es-US", name: "Spanish (Latin American)" },
|
||||
{ id: "fr-CA", name: "French (Canadian)" },
|
||||
{ id: "fr-FR", name: "French" },
|
||||
{ id: "hi-IN", name: "Hindi" },
|
||||
{ id: "is-IS", name: "Icelandic" },
|
||||
{ id: "it-IT", name: "Italian" },
|
||||
{ id: "ja-JP", name: "Japanese" },
|
||||
{ id: "ko-KR", name: "Korean" },
|
||||
{ id: "nb-NO", name: "Norwegian" },
|
||||
{ id: "nl-NL", name: "Dutch" },
|
||||
{ id: "pl-PL", name: "Polish" },
|
||||
{ id: "pt-BR", name: "Portuguese (Brazilian)" },
|
||||
{ id: "pt-PT", name: "Portuguese (European)" },
|
||||
{ id: "ro-RO", name: "Romanian" },
|
||||
{ id: "ru-RU", name: "Russian" },
|
||||
{ id: "sv-SE", name: "Swedish" },
|
||||
{ id: "tr-TR", name: "Turkish" },
|
||||
{ id: "zh-CN", name: "Chinese (Mandarin)" },
|
||||
],
|
||||
voices: {
|
||||
arb: [{ id: "Polly.Zeina", name: "Zeina" }],
|
||||
"cy-GB": [{ id: "Polly.Gwyneth", name: "Gwyneth" }],
|
||||
"da-DK": [{ id: "Polly.Naja", name: "Naja" }],
|
||||
"de-DE": [{ id: "Polly.Marlene", name: "Marlene" }],
|
||||
"en-US": [{ id: "Polly.Salli", name: "Salli" }],
|
||||
"en-AU": [{ id: "Polly.Nicole", name: "Nicole" }],
|
||||
"en-GB": [{ id: "Polly.Amy", name: "Amy" }],
|
||||
"en-GB-WLS": [{ id: "Polly.Geraint", name: "Geraint" }],
|
||||
"en-IN": [{ id: "Polly.Aditi", name: "Aditi" }],
|
||||
"es-ES": [{ id: "Polly.Conchita", name: "Conchita" }],
|
||||
"es-MX": [{ id: "Polly.Mia", name: "Mia" }],
|
||||
"es-US": [{ id: "Polly.Penelope", name: "Penelope" }],
|
||||
"fr-CA": [{ id: "Polly.Chantal", name: "Chantal" }],
|
||||
"fr-FR": [{ id: "Polly.Celine", name: "Celine" }],
|
||||
"hi-IN": [{ id: "Polly.Aditi", name: "Aditi" }],
|
||||
"is-IS": [{ id: "Polly.Dora", name: "Dora" }],
|
||||
"it-IT": [{ id: "Polly.Carla", name: "Carla" }],
|
||||
"ja-JP": [{ id: "Polly.Mizuki", name: "Mizuki" }],
|
||||
"ko-KR": [{ id: "Polly.Seoyeon", name: "Seoyeon" }],
|
||||
"nb-NO": [{ id: "Polly.Liv", name: "Liv" }],
|
||||
"nl-NL": [{ id: "Polly.Lotte", name: "Lotte" }],
|
||||
"pl-PL": [{ id: "Polly.Ewa", name: "Ewa" }],
|
||||
"pt-BR": [{ id: "Polly.Vitoria", name: "Vitoria" }],
|
||||
"pt-PT": [{ id: "Polly.Ines", name: "Ines" }],
|
||||
"ro-RO": [{ id: "Polly.Carmen", name: "Carmen" }],
|
||||
"ru-RU": [{ id: "Polly.Tatyana", name: "Tatyana" }],
|
||||
"sv-SE": [{ id: "Polly.Astrid", name: "Astrid" }],
|
||||
"tr-TR": [{ id: "Polly.Filiz", name: "Filiz" }],
|
||||
"zh-CN": [{ id: "Polly.Zhiyu", name: "Zhiyu" }],
|
||||
},
|
||||
};
|
||||
export default languages;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
SimpleForm,
|
||||
FormDataConsumer,
|
||||
TextInput,
|
||||
Create,
|
||||
ArrayInput,
|
||||
SimpleFormIterator,
|
||||
regex,
|
||||
required,
|
||||
CreateProps,
|
||||
} from "react-admin";
|
||||
import { BackendTypeInput, BackendIdInput, HttpMethodInput } from "./shared";
|
||||
/*
|
||||
|
||||
|
||||
<ReferenceInput
|
||||
label="Voice Line"
|
||||
source="voiceLineId"
|
||||
reference="voiceLines"
|
||||
validate={[required()]}
|
||||
>
|
||||
<SelectInput optionText="number" />
|
||||
</ReferenceInput>
|
||||
*/
|
||||
const WebhookCreate = (props: CreateProps) => (
|
||||
<Create {...props} title="Create Webhooks">
|
||||
<SimpleForm>
|
||||
<TextInput source="name" validate={[required()]} />
|
||||
<BackendTypeInput />
|
||||
<FormDataConsumer subscription={{ values: true }}>
|
||||
{BackendIdInput}
|
||||
</FormDataConsumer>
|
||||
<TextInput
|
||||
source="endpointUrl"
|
||||
validate={[required(), regex(/^https?:\/\/[^/]+/, "validation.url")]}
|
||||
/>
|
||||
<HttpMethodInput />
|
||||
<ArrayInput source="headers">
|
||||
<SimpleFormIterator>
|
||||
<TextInput
|
||||
source="header"
|
||||
validate={[required(), regex(/^[\w-]+$/, "validation.headerName")]}
|
||||
/>
|
||||
<TextInput source="value" validate={[required()]} />
|
||||
</SimpleFormIterator>
|
||||
</ArrayInput>
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
|
||||
export default WebhookCreate;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
Edit,
|
||||
ArrayInput,
|
||||
SimpleFormIterator,
|
||||
regex,
|
||||
required,
|
||||
EditProps,
|
||||
FormDataConsumer,
|
||||
} from "react-admin";
|
||||
import { BackendTypeInput, BackendIdInput, HttpMethodInput } from "./shared";
|
||||
|
||||
const WebhookTitle = ({ record }: any) => {
|
||||
let title = "";
|
||||
if (record) title = record.name ?? record.email;
|
||||
return <span>Webhook {title}</span>;
|
||||
};
|
||||
|
||||
const WebhookEdit = (props: EditProps) => (
|
||||
<Edit title={<WebhookTitle />} {...props}>
|
||||
<SimpleForm>
|
||||
<TextInput source="name" validate={[required()]} />
|
||||
<BackendTypeInput />
|
||||
<FormDataConsumer subscription={{ values: true }}>
|
||||
{BackendIdInput}
|
||||
</FormDataConsumer>
|
||||
<TextInput
|
||||
source="endpointUrl"
|
||||
validate={[required(), regex(/^https?:\/\/[^/]+/, "validation.url")]}
|
||||
/>
|
||||
<HttpMethodInput />
|
||||
<ArrayInput source="headers">
|
||||
<SimpleFormIterator>
|
||||
<TextInput
|
||||
source="header"
|
||||
validate={[required(), regex(/^[\w-]+$/, "validation.headerName")]}
|
||||
/>
|
||||
<TextInput source="value" validate={[required()]} />
|
||||
</SimpleFormIterator>
|
||||
</ArrayInput>
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
|
||||
export default WebhookEdit;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { List, Datagrid, DateField, TextField, ListProps } from "react-admin";
|
||||
import { BackendIdField } from "./shared";
|
||||
|
||||
const WebhookList = (props: ListProps) => (
|
||||
<List {...props} exporter={false}>
|
||||
<Datagrid rowClick="edit">
|
||||
<TextField source="name" />
|
||||
<TextField source="backendType" />
|
||||
<BackendIdField source="backendId" />
|
||||
<DateField source="createdAt" />
|
||||
<DateField source="updatedAt" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export default WebhookList;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import WebhookIcon from "@mui/icons-material/Send";
|
||||
import WebhookList from "./WebhookList";
|
||||
import WebhookEdit from "./WebhookEdit";
|
||||
import WebhookCreate from "./WebhookCreate";
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default {
|
||||
list: WebhookList,
|
||||
create: WebhookCreate,
|
||||
edit: WebhookEdit,
|
||||
icon: WebhookIcon,
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import { SelectInput, required } from "react-admin";
|
||||
|
||||
import {
|
||||
VoiceLineField,
|
||||
VoiceLineSelectInput,
|
||||
} from "../voice/voicelines/shared";
|
||||
import {
|
||||
WhatsAppBotField,
|
||||
WhatsAppBotSelectInput,
|
||||
} from "../whatsapp/bots/shared";
|
||||
import { SignalBotField, SignalBotSelectInput } from "../signal/bots/shared";
|
||||
|
||||
const httpChoices = [
|
||||
{ id: "post", name: "POST" },
|
||||
{ id: "put", name: "PUT" },
|
||||
];
|
||||
export const HttpMethodInput = (props: any) => (
|
||||
<SelectInput
|
||||
source="httpMethod"
|
||||
choices={httpChoices}
|
||||
validate={[required()]}
|
||||
initialValue="post"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const backendChoices = [
|
||||
{ id: "signal", name: "Signal" },
|
||||
{ id: "whatsapp", name: "WhatsApp" },
|
||||
{ id: "voice", name: "Voice" },
|
||||
];
|
||||
|
||||
const backendInputComponents = {
|
||||
whatsapp: WhatsAppBotSelectInput("backendId"),
|
||||
signal: SignalBotSelectInput("backendId"),
|
||||
voice: VoiceLineSelectInput("backendId"),
|
||||
};
|
||||
|
||||
const backendFieldComponents = {
|
||||
whatsapp: WhatsAppBotField("backendId"),
|
||||
signal: SignalBotField("backendId"),
|
||||
voice: VoiceLineField("backendId"),
|
||||
};
|
||||
|
||||
export const BackendTypeInput = (props: any) => (
|
||||
<SelectInput
|
||||
source="backendType"
|
||||
choices={backendChoices}
|
||||
validate={[required()]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const BackendIdInput = (form: any, ...rest: any[]) => {
|
||||
const Component = form.formData.backendType
|
||||
? backendInputComponents[form.formData.backendType]
|
||||
: false;
|
||||
return <>{Component && <Component form={form} {...rest} />}</>;
|
||||
};
|
||||
|
||||
export const BackendIdField = (form: any, ...rest: any[]) => {
|
||||
console.log(form);
|
||||
|
||||
const Component = form.record.backendType
|
||||
? backendFieldComponents[form.record.backendType]
|
||||
: false;
|
||||
return <>{Component && <Component form={form} {...rest} />}</>;
|
||||
};
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { List, Datagrid, TextField } from "react-admin";
|
||||
|
||||
const WhatsappAttachmentList = (props: any) => (
|
||||
<List {...props} exporter={false}>
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="id" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export default WhatsappAttachmentList;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { Show, ShowProps, SimpleShowLayout, TextField } from "react-admin";
|
||||
|
||||
const WhatsappAttachmentShow = (props: ShowProps) => (
|
||||
<Show {...props} title="Whatsapp Attachment">
|
||||
<SimpleShowLayout>
|
||||
<TextField source="id" />
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
);
|
||||
|
||||
export default WhatsappAttachmentShow;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import WhatsappAttachmentIcon from "@mui/icons-material/AttachFile";
|
||||
import WhatsappAttachmentList from "./WhatsappAttachmentList";
|
||||
import WhatsappAttachmentShow from "./WhatsappAttachmentShow";
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default {
|
||||
list: WhatsappAttachmentList,
|
||||
show: WhatsappAttachmentShow,
|
||||
icon: WhatsappAttachmentIcon,
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
// import dynamic from "next/dynamic";
|
||||
import { FC } from "react";
|
||||
import {
|
||||
SimpleForm,
|
||||
Create,
|
||||
TextInput,
|
||||
required,
|
||||
CreateProps,
|
||||
} from "react-admin";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { validateE164Number } from "../../../_lib/phone-numbers";
|
||||
|
||||
const WhatsappBotCreate: FC<CreateProps> = (props) => {
|
||||
// const MuiPhoneNumber = dynamic(() => import("material-ui-phone-number"), {
|
||||
// ssr: false,
|
||||
// });
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<Create {...props} title="Create Whatsapp Bot" redirect="show">
|
||||
<SimpleForm>
|
||||
<TextInput
|
||||
source="userId"
|
||||
defaultValue={
|
||||
// @ts-expect-error: non-existent property
|
||||
session.user.id
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
source="phoneNumber"
|
||||
validate={[validateE164Number, required()]}
|
||||
/>
|
||||
{/* <MuiPhoneNumber
|
||||
defaultCountry={"us"}
|
||||
fullWidth
|
||||
onChange={(e: any) => setFieldValue("phoneNumber", e)}
|
||||
/> */}
|
||||
<TextInput source="description" />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatsappBotCreate;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { SimpleForm, Edit, TextInput, required, EditProps } from "react-admin";
|
||||
|
||||
const WhatsappBotEdit = (props: EditProps) => (
|
||||
<Edit {...props} title="Edit Bot">
|
||||
<SimpleForm>
|
||||
<TextInput source="phoneNumber" validate={[required()]} />
|
||||
<TextInput source="description" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
|
||||
export default WhatsappBotEdit;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
List,
|
||||
Datagrid,
|
||||
DateField,
|
||||
TextField,
|
||||
BooleanField,
|
||||
} from "react-admin";
|
||||
|
||||
const WhatsappBotList = (props: any) => (
|
||||
<List {...props} exporter={false}>
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="phoneNumber" />
|
||||
<TextField source="description" />
|
||||
<BooleanField source="isVerified" />
|
||||
<DateField source="createdAt" />
|
||||
<DateField source="updatedAt" />
|
||||
<TextField source="createdBy" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export default WhatsappBotList;
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Typography,
|
||||
Grid,
|
||||
Button,
|
||||
TextField as MaterialTextField,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
TextField,
|
||||
ShowProps,
|
||||
useGetOne,
|
||||
useRefresh,
|
||||
BooleanField,
|
||||
} from "react-admin";
|
||||
import QRCode from "react-qr-code";
|
||||
import useSWR from "swr";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
|
||||
const Sidebar = ({ record }: any) => {
|
||||
const [receivedMessages, setReceivedMessages] = useState([]);
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const handlePhoneNumberChange = (event: any) => {
|
||||
setPhoneNumber(event.target.value);
|
||||
};
|
||||
|
||||
const [message, setMessage] = useState("");
|
||||
const handleMessageChange = (event: any) => {
|
||||
setMessage(event.target.value);
|
||||
};
|
||||
|
||||
const sendMessage = async (phoneNumber: string, message: string) => {
|
||||
await fetch(`/api/v1/whatsapp/bots/${record.token}/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ phoneNumber, message }),
|
||||
});
|
||||
};
|
||||
|
||||
const receiveMessages = async () => {
|
||||
const result = await fetch(`/api/v1/whatsapp/bots/${record.token}/receive`);
|
||||
const msgs = await result.json();
|
||||
console.log(msgs);
|
||||
setReceivedMessages(msgs);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ width: "33%", marginLeft: 20, padding: 14 }}>
|
||||
<Grid container direction="column" spacing={2}>
|
||||
<Grid item>
|
||||
<Typography variant="h6">Send message</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<MaterialTextField
|
||||
variant="outlined"
|
||||
label="Phone number"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={phoneNumber}
|
||||
onChange={handlePhoneNumberChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<MaterialTextField
|
||||
variant="outlined"
|
||||
label="Message"
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
size="small"
|
||||
value={message}
|
||||
onChange={handleMessageChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item container direction="row-reverse">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => sendMessage(phoneNumber, message)}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item container direction="row">
|
||||
<Grid item>
|
||||
<Typography variant="h6">Receive messages</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<IconButton
|
||||
onClick={receiveMessages}
|
||||
color="primary"
|
||||
style={{ marginTop: -12 }}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{receivedMessages.map((receivedMessage: any, index: number) => (
|
||||
<Grid key={index} item container direction="column" spacing={1}>
|
||||
<Grid item style={{ fontWeight: "bold", color: "#999" }}>
|
||||
{receivedMessage.key.remoteJid.replace("@s.whatsapp.net", "")}
|
||||
</Grid>
|
||||
<Grid item style={{ borderBottom: "1px solid #999" }}>
|
||||
{receivedMessage.message.conversation}
|
||||
</Grid>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const WhatsappBotShow = (props: ShowProps) => {
|
||||
const refresh = useRefresh();
|
||||
const { data } = useGetOne("whatsappBots", {id: props.id});
|
||||
|
||||
const { data: registerData, error: registerError } = useSWR(
|
||||
data && !data?.isVerified
|
||||
? `/api/v1/whatsapp/bots/${props.id}/register`
|
||||
: undefined,
|
||||
{ refreshInterval: 59000 }
|
||||
);
|
||||
|
||||
const unverifyBot = async () => {
|
||||
await fetch(`/api/v1/whatsapp/bots/${props.id}/unverify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ verified: false }),
|
||||
});
|
||||
};
|
||||
|
||||
console.log({ registerData, registerError });
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !data?.isVerified) {
|
||||
const interval = setInterval(() => {
|
||||
refresh();
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [refresh, data]);
|
||||
|
||||
return (
|
||||
<Show {...props} title="Bot" aside={<Sidebar />}>
|
||||
<SimpleShowLayout>
|
||||
<TextField source="phoneNumber" />
|
||||
<BooleanField source="isVerified" />
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
style={{ color: "black", backgroundColor: "#ddd" }}
|
||||
onClick={async () => unverifyBot()}
|
||||
>
|
||||
Unverify
|
||||
</Button>
|
||||
<TextField source="description" />
|
||||
<TextField source="token" />
|
||||
{!data?.isVerified && data?.qrCode && data?.qrCode !== "" && (
|
||||
<QRCode value={data.qrCode} />
|
||||
)}
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatsappBotShow;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import WhatsappBotIcon from "@mui/icons-material/WhatsApp";
|
||||
import WhatsappBotList from "./WhatsappBotList";
|
||||
import WhatsappBotEdit from "./WhatsappBotEdit";
|
||||
import WhatsappBotCreate from "./WhatsappBotCreate";
|
||||
import WhatsappBotShow from "./WhatsappBotShow";
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default {
|
||||
list: WhatsappBotList,
|
||||
create: WhatsappBotCreate,
|
||||
edit: WhatsappBotEdit,
|
||||
show: WhatsappBotShow,
|
||||
icon: WhatsappBotIcon,
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
import {
|
||||
SelectInput,
|
||||
required,
|
||||
ReferenceInput,
|
||||
ReferenceField,
|
||||
TextField,
|
||||
} from "react-admin";
|
||||
|
||||
export const WhatsAppBotSelectInput = (source: string) => () =>
|
||||
(
|
||||
<ReferenceInput
|
||||
label="WhatsApp Bot"
|
||||
reference="whatsappBots"
|
||||
source={source}
|
||||
validate={[required()]}
|
||||
>
|
||||
<SelectInput optionText="phoneNumber" />
|
||||
</ReferenceInput>
|
||||
);
|
||||
|
||||
export const WhatsAppBotField = (source: string) => () =>
|
||||
(
|
||||
<ReferenceField
|
||||
label="WhatsApp Bot"
|
||||
reference="whatsappBots"
|
||||
source={source}
|
||||
>
|
||||
<TextField source="phoneNumber" />
|
||||
</ReferenceField>
|
||||
);
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
List,
|
||||
ListProps,
|
||||
Datagrid,
|
||||
DateField,
|
||||
TextField,
|
||||
BooleanField,
|
||||
} from "react-admin";
|
||||
|
||||
const WhatsappMessageList = (props: ListProps) => (
|
||||
<List {...props} exporter={false}>
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="phoneNumber" />
|
||||
<TextField source="description" />
|
||||
<BooleanField source="isVerified" />
|
||||
<DateField source="createdAt" />
|
||||
<DateField source="updatedAt" />
|
||||
<TextField source="createdBy" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export default WhatsappMessageList;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Show,
|
||||
ShowProps,
|
||||
SimpleShowLayout,
|
||||
TextField,
|
||||
ReferenceManyField,
|
||||
Datagrid,
|
||||
} from "react-admin";
|
||||
|
||||
const WhatsappMessageShow = (props: ShowProps) => (
|
||||
<Show {...props} title="Whatsapp Message">
|
||||
<SimpleShowLayout>
|
||||
<TextField source="waMessage" />
|
||||
<TextField source="createdAt" />
|
||||
<ReferenceManyField
|
||||
label="Attachments"
|
||||
reference="whatsappAttachments"
|
||||
target="whatsappMessageId"
|
||||
>
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="id" />
|
||||
<TextField source="createdAt" />
|
||||
</Datagrid>
|
||||
</ReferenceManyField>
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
);
|
||||
|
||||
export default WhatsappMessageShow;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import WhatsappMessageIcon from "@mui/icons-material/Message";
|
||||
import WhatsappMessageList from "./WhatsappMessageList";
|
||||
import WhatsappMessageShow from "./WhatsappMessageShow";
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default {
|
||||
list: WhatsappMessageList,
|
||||
show: WhatsappMessageShow,
|
||||
icon: WhatsappMessageIcon,
|
||||
};
|
||||
81
apps/link/app/(main)/admin/metamigo/_i18n/en.ts
Normal file
81
apps/link/app/(main)/admin/metamigo/_i18n/en.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { TranslationMessages } from "react-admin";
|
||||
import englishMessages from "ra-language-english";
|
||||
|
||||
const customEnglishMessages: TranslationMessages = {
|
||||
...englishMessages,
|
||||
|
||||
auth: {
|
||||
loggingIn: "Logging in...",
|
||||
permissionDenied: "Permission denied",
|
||||
},
|
||||
pos: {
|
||||
configuration: "Configuration",
|
||||
menu: {
|
||||
security: "Security",
|
||||
accounts: "Accounts",
|
||||
voicelines: "Voice Lines",
|
||||
providers: "Voice Provider",
|
||||
webhooks: "Webhooks",
|
||||
voice: "Voice",
|
||||
whatsapp: "WhatsApp",
|
||||
signal: "Signal",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
signalBots: {
|
||||
name: "Signal Bot |||| Signal Bots",
|
||||
verifyDialog: {
|
||||
sms: "Please enter the verification code sent via SMS to %{phoneNumber}",
|
||||
voice:
|
||||
"Please answer the call from Signal to %{phoneNumber} and enter the verification code",
|
||||
},
|
||||
},
|
||||
whatsappBots: {
|
||||
name: "WhatsApp Bot |||| WhatsApp Bots",
|
||||
},
|
||||
users: {
|
||||
name: "User |||| Users",
|
||||
},
|
||||
accounts: {
|
||||
name: "OAuth Account |||| OAuth Accounts",
|
||||
},
|
||||
voicelines: {
|
||||
name: "Voice Line |||| Voice Lines",
|
||||
fields: {
|
||||
providerLineSid: "Provider Line SID",
|
||||
},
|
||||
},
|
||||
providers: {
|
||||
name: "Voice Provider |||| Voice Providers",
|
||||
fields: {
|
||||
credentials: {
|
||||
accountSid: "Twilio Account SID",
|
||||
apiKeySid: "Twilio API Key SID",
|
||||
apiKeySecret: "Twilio API Key Secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
webhooks: {
|
||||
name: "Webhook |||| Webhooks",
|
||||
fields: {
|
||||
endpointUrl: "Endpoint URL",
|
||||
httpMethod: "HTTP Method",
|
||||
headers: "HTTP Headers",
|
||||
header: "Header Name",
|
||||
value: "Header Value",
|
||||
},
|
||||
},
|
||||
},
|
||||
validation: {
|
||||
url: "a valid url starting with https:// is required",
|
||||
voice: "a voice is required",
|
||||
language: "a language is required",
|
||||
headerName: "a valid http header name has only letters, numbers and dashes",
|
||||
noAvailableNumbers:
|
||||
"There are no available numbers to assign. Please visit the provider and purchase more numbers.",
|
||||
noVoiceLines:
|
||||
"There are no configured voice lines. Visit the Voice Lines admin page to create some.",
|
||||
},
|
||||
};
|
||||
|
||||
export default customEnglishMessages;
|
||||
35
apps/link/app/(main)/admin/metamigo/_lib/absolute-url.ts
Normal file
35
apps/link/app/(main)/admin/metamigo/_lib/absolute-url.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { IncomingMessage } from "node:http";
|
||||
|
||||
function absoluteUrl(
|
||||
req?: IncomingMessage,
|
||||
localhostAddress = "localhost:3000"
|
||||
) {
|
||||
let host =
|
||||
(req?.headers ? req.headers.host : window.location.host) ||
|
||||
localhostAddress;
|
||||
let protocol = /^localhost(:\d+)?$/.test(host) ? "http:" : "https:";
|
||||
|
||||
if (
|
||||
req &&
|
||||
req.headers["x-forwarded-host"] &&
|
||||
typeof req.headers["x-forwarded-host"] === "string"
|
||||
) {
|
||||
host = req.headers["x-forwarded-host"];
|
||||
}
|
||||
|
||||
if (
|
||||
req &&
|
||||
req.headers["x-forwarded-proto"] &&
|
||||
typeof req.headers["x-forwarded-proto"] === "string"
|
||||
) {
|
||||
protocol = `${req.headers["x-forwarded-proto"]}:`;
|
||||
}
|
||||
|
||||
return {
|
||||
protocol,
|
||||
host,
|
||||
origin: protocol + "//" + host,
|
||||
};
|
||||
}
|
||||
|
||||
export default absoluteUrl;
|
||||
40
apps/link/app/(main)/admin/metamigo/_lib/apollo-client.ts
Normal file
40
apps/link/app/(main)/admin/metamigo/_lib/apollo-client.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import {
|
||||
ApolloClient,
|
||||
InMemoryCache,
|
||||
ApolloLink,
|
||||
HttpLink,
|
||||
} from "@apollo/client";
|
||||
import { onError } from "@apollo/client/link/error";
|
||||
|
||||
const errorLink = onError(
|
||||
({ operation, graphQLErrors, networkError, forward }) => {
|
||||
console.log("ERROR LINK", operation);
|
||||
if (graphQLErrors)
|
||||
graphQLErrors.map(({ message, locations, path, ...rest }) =>
|
||||
console.log(
|
||||
`[GraphQL error]: Message: ${message}`,
|
||||
locations,
|
||||
path,
|
||||
rest
|
||||
)
|
||||
);
|
||||
if (networkError) console.log(`[Network error]: ${networkError}`);
|
||||
forward(operation);
|
||||
}
|
||||
);
|
||||
|
||||
export const apolloClient = new ApolloClient({
|
||||
link: ApolloLink.from([errorLink, new HttpLink({ uri: "/proxy/metamigo/graphql" })]),
|
||||
cache: new InMemoryCache(),
|
||||
/*
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
fetchPolicy: "no-cache",
|
||||
errorPolicy: "ignore",
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: "no-cache",
|
||||
errorPolicy: "all",
|
||||
},
|
||||
}, */
|
||||
});
|
||||
213
apps/link/app/(main)/admin/metamigo/_lib/cloudflare.ts
Normal file
213
apps/link/app/(main)/admin/metamigo/_lib/cloudflare.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { promisify } from "node:util";
|
||||
import jwt from "jsonwebtoken";
|
||||
import jwksClient from "jwks-rsa";
|
||||
import * as Boom from "@hapi/boom";
|
||||
import * as Wreck from "@hapi/wreck";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import type { Adapter } from "next-auth/adapters";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
|
||||
const CF_JWT_HEADER_NAME = "cf-access-jwt-assertion";
|
||||
const CF_JWT_ALGOS = ["RS256"];
|
||||
|
||||
export type VerifyFn = (token: string) => Promise<any>;
|
||||
|
||||
/**
|
||||
* Returns a function that will accept a jwt and verify it against the cloudflare access details
|
||||
*
|
||||
* @param audience the cloudflare access audience id
|
||||
* @param domain the cloudflare access domain
|
||||
*/
|
||||
export const cfVerifier = (audience: string, domain: string): VerifyFn => {
|
||||
if (!audience || !domain)
|
||||
throw Boom.badImplementation(
|
||||
"Cloudflare configuration is missing. See project documentation."
|
||||
);
|
||||
const issuer = `https://${domain}`;
|
||||
const client = jwksClient({
|
||||
jwksUri: `${issuer}/cdn-cgi/access/certs`,
|
||||
});
|
||||
|
||||
return async (token) => {
|
||||
const getKey = (header: any, callback: any) => {
|
||||
client.getSigningKey(header.kid, (err: any, key: any) => {
|
||||
if (err)
|
||||
throw Boom.serverUnavailable(
|
||||
"failed to fetch cloudflare access jwks"
|
||||
);
|
||||
callback(undefined, key.getPublicKey());
|
||||
});
|
||||
};
|
||||
|
||||
const opts = {
|
||||
algorithms: CF_JWT_ALGOS,
|
||||
audience,
|
||||
issuer,
|
||||
};
|
||||
// @ts-expect-error: Too many args
|
||||
return promisify(jwt.verify)(token, getKey, opts);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies the Cloudflare Access JWT and returns the decoded token's contents.
|
||||
* Throws if the token is missing or invalid.
|
||||
*
|
||||
* @param verifier the verification function
|
||||
* @param req the incoming http request to verify
|
||||
* @return the original token and the decoded contents.
|
||||
*/
|
||||
export const verifyRequest = async (
|
||||
verifier: VerifyFn,
|
||||
req: IncomingMessage
|
||||
): Promise<{ token: string; decoded: any }> => {
|
||||
const token = req.headers[CF_JWT_HEADER_NAME] as string;
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = await verifier(token);
|
||||
return { token, decoded };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw Boom.unauthorized("invalid cloudflare access token");
|
||||
}
|
||||
}
|
||||
|
||||
throw Boom.unauthorized("cloudflare access token missing");
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches user identity information from cloudflare.
|
||||
*
|
||||
* @param domain the cloudflare access domain
|
||||
* @param token the encoded jwt token for the user
|
||||
* @see https://developers.cloudflare.com/access/setting-up-access/json-web-token#groups-within-a-jwt
|
||||
*/
|
||||
export const getIdentity = async (
|
||||
domain: string,
|
||||
token: string
|
||||
): Promise<any> => {
|
||||
const { payload } = await Wreck.get(
|
||||
`https://${domain}/cdn-cgi/access/get-identity`,
|
||||
{
|
||||
headers: {
|
||||
Cookie: `CF_Authorization=${token}`,
|
||||
},
|
||||
json: true,
|
||||
}
|
||||
);
|
||||
return payload;
|
||||
};
|
||||
|
||||
const cloudflareAccountProvider = "cloudflare-access";
|
||||
|
||||
const cloudflareAuthorizeCallback =
|
||||
(
|
||||
req: IncomingMessage,
|
||||
domain: string,
|
||||
verifier: VerifyFn,
|
||||
adapter: Adapter
|
||||
): (() => Promise<any>) =>
|
||||
async () => {
|
||||
/*
|
||||
|
||||
lots of little variables in here.
|
||||
|
||||
token: the encoded jwt from cloudflare access
|
||||
decoded: the decoded jwt containing the content cloudflare gives us
|
||||
identity: we call the cloudflare access identity endpoint to retrieve more user identity information
|
||||
this data is identity provider specific, so the format is unknown
|
||||
it would be possible to support specific identity providers and have roles/groups
|
||||
profile: this is the accumulated user information we have that we will fetch/build the user record with
|
||||
*/
|
||||
|
||||
const { token, decoded } = await verifyRequest(verifier, req);
|
||||
|
||||
const profile = {
|
||||
email: undefined,
|
||||
name: undefined,
|
||||
avatar: undefined,
|
||||
};
|
||||
if (decoded.email) profile.email = decoded.email;
|
||||
if (decoded.name) profile.name = decoded.name;
|
||||
const identity = await getIdentity(domain, token);
|
||||
|
||||
if (identity.email) profile.email = identity.email;
|
||||
if (identity.name) profile.name = identity.name;
|
||||
|
||||
if (!profile.email)
|
||||
throw new Error("cloudflare access authorization: email not found");
|
||||
|
||||
const providerId = `cfaccess|${identity.idp.type}|${identity.idp.id}`;
|
||||
const providerAccountId = identity.user_uuid;
|
||||
|
||||
if (!providerAccountId)
|
||||
throw new Error(
|
||||
"cloudflare access authorization: missing provider account id"
|
||||
);
|
||||
|
||||
const {
|
||||
getUserByProviderAccountId,
|
||||
getUserByEmail,
|
||||
createUser,
|
||||
linkAccount,
|
||||
} =
|
||||
// @ts-expect-error: non-existent property
|
||||
await adapter.getAdapter({} as any);
|
||||
|
||||
const userByProviderAccountId = await getUserByProviderAccountId(
|
||||
providerId,
|
||||
providerAccountId
|
||||
);
|
||||
if (userByProviderAccountId) {
|
||||
return userByProviderAccountId;
|
||||
}
|
||||
|
||||
const userByEmail = await getUserByEmail(profile.email);
|
||||
if (userByEmail) {
|
||||
// we will not explicitly link accounts
|
||||
throw new Error(
|
||||
"cloudflare access authorization: user exists for email address, but is not linked."
|
||||
);
|
||||
}
|
||||
|
||||
const user = await createUser(profile);
|
||||
|
||||
// between the previous line and the next line exists a transactional bug
|
||||
// https://github.com/nextauthjs/next-auth/issues/876
|
||||
// hopefully we don't experience it
|
||||
|
||||
await linkAccount(
|
||||
user.id,
|
||||
providerId,
|
||||
cloudflareAccountProvider,
|
||||
providerAccountId,
|
||||
// the following are unused but are specified for completness
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param audience the cloudflare access audience id
|
||||
* @param domain the cloudflare access domain (including the .cloudflareaccess.com bit)
|
||||
* @param adapter the next-auth adapter used to talk to the backend
|
||||
* @param req the incoming request object used to parse the jwt from
|
||||
*/
|
||||
export const CloudflareAccessProvider = (
|
||||
audience: string,
|
||||
domain: string,
|
||||
adapter: Adapter,
|
||||
req: IncomingMessage
|
||||
) => {
|
||||
const verifier = cfVerifier(audience, domain);
|
||||
|
||||
return Credentials({
|
||||
id: cloudflareAccountProvider,
|
||||
name: "Cloudflare Access",
|
||||
credentials: {},
|
||||
authorize: cloudflareAuthorizeCallback(req, domain, verifier, adapter),
|
||||
});
|
||||
};
|
||||
12
apps/link/app/(main)/admin/metamigo/_lib/dataprovider.ts
Normal file
12
apps/link/app/(main)/admin/metamigo/_lib/dataprovider.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import pgDataProvider from "ra-postgraphile";
|
||||
import schema from "./graphql-schema.json";
|
||||
|
||||
export const metamigoDataProvider = async (client: any) => {
|
||||
const graphqlDataProvider: any = await pgDataProvider(
|
||||
client,
|
||||
// @ts-expect-error: Missing property
|
||||
{},
|
||||
{ introspection: { schema: schema.data.__schema } }
|
||||
);
|
||||
return graphqlDataProvider;
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
230
apps/link/app/(main)/admin/metamigo/_lib/nextauth-adapter.ts
Normal file
230
apps/link/app/(main)/admin/metamigo/_lib/nextauth-adapter.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/* eslint-disable unicorn/no-null */
|
||||
import type {
|
||||
Adapter,
|
||||
AdapterAccount,
|
||||
AdapterSession,
|
||||
AdapterUser,
|
||||
} from "next-auth/adapters";
|
||||
import * as Wreck from "@hapi/wreck";
|
||||
import * as Boom from "@hapi/boom";
|
||||
|
||||
import type { IAppConfig } from "@digiresilience/metamigo-config";
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: string;
|
||||
userRole: string;
|
||||
avatar?: string;
|
||||
image?: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export type User = Profile & { id: string; createdAt: Date; updatedAt: Date };
|
||||
|
||||
export interface Session {
|
||||
userId: string;
|
||||
expires: Date;
|
||||
sessionToken: string;
|
||||
accessToken: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// from https://github.com/nextauthjs/next-auth/blob/main/src/lib/errors.js
|
||||
class UnknownError extends Error {
|
||||
constructor(message: any) {
|
||||
super(message);
|
||||
this.name = "UnknownError";
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
// stack: this.stack
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class CreateUserError extends UnknownError {
|
||||
constructor(message: any) {
|
||||
super(message);
|
||||
this.name = "CreateUserError";
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
const basicHeader = (secret: any) =>
|
||||
"Basic " + Buffer.from(secret + ":", "utf8").toString("base64");
|
||||
|
||||
export const MetamigoAdapter = (config: IAppConfig): Adapter => {
|
||||
if (!config) throw new Error("MetamigoAdapter: config is not defined.");
|
||||
const wreck = Wreck.defaults({
|
||||
headers: {
|
||||
authorization: basicHeader(config.nextAuth.secret),
|
||||
},
|
||||
baseUrl: `${config.frontend.apiUrl}/api/nextauth/`,
|
||||
maxBytes: 1024 * 1024,
|
||||
json: "force",
|
||||
});
|
||||
|
||||
function getAdapter(): Adapter {
|
||||
async function createUser(profile: Profile) {
|
||||
try {
|
||||
if (!profile.createdBy) profile = { ...profile, createdBy: "nextauth" };
|
||||
profile.avatar = profile.image;
|
||||
delete profile.image;
|
||||
const { payload } = await wreck.post("createUser", {
|
||||
payload: profile,
|
||||
});
|
||||
return payload;
|
||||
} catch {
|
||||
throw new CreateUserError("CREATE_USER_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function getUser(id: string) {
|
||||
try {
|
||||
const { payload } = await wreck.get(`getUser/${id}`);
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (Boom.isBoom(error, 404)) return null;
|
||||
throw new Error("GET_USER_BY_ID_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserByEmail(email: string) {
|
||||
try {
|
||||
const { payload } = await wreck.get(`getUserByEmail/${email}`);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (Boom.isBoom(error, 404)) return null;
|
||||
throw new Error("GET_USER_BY_EMAIL_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserByAccount({
|
||||
providerAccountId,
|
||||
provider,
|
||||
}: {
|
||||
providerAccountId: string;
|
||||
provider: string;
|
||||
}) {
|
||||
try {
|
||||
const { payload } = await wreck.get(
|
||||
`getUserByAccount/${provider}/${providerAccountId}`
|
||||
);
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (Boom.isBoom(error, 404)) return null;
|
||||
console.log(error);
|
||||
throw new Error("GET_USER_BY_ACCOUNT");
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(user: User) {
|
||||
try {
|
||||
const { payload } = await wreck.put("updateUser", {
|
||||
payload: user,
|
||||
});
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
throw new Error("UPDATE_USER");
|
||||
}
|
||||
}
|
||||
|
||||
async function linkAccount(account: AdapterAccount) {
|
||||
try {
|
||||
await wreck.put("linkAccount", { payload: account } as any);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new Error("LINK_ACCOUNT_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(user: User) {
|
||||
try {
|
||||
const { payload }: { payload: AdapterSession } = await wreck.post(
|
||||
"createSession",
|
||||
{
|
||||
payload: user,
|
||||
}
|
||||
);
|
||||
payload.expires = new Date(payload.expires);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new Error("CREATE_SESSION_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function getSessionAndUser(sessionToken: string) {
|
||||
try {
|
||||
const { payload }: { payload: any } = await wreck.get(
|
||||
`getSessionAndUser/${sessionToken}`
|
||||
);
|
||||
const {
|
||||
session,
|
||||
user,
|
||||
}: { session: AdapterSession; user: AdapterUser } = payload;
|
||||
session.expires = new Date(session.expires);
|
||||
return { session, user };
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
if (Boom.isBoom(error, 404)) return null;
|
||||
throw new Error("GET_SESSION_AND_USER_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSession(session: Session, force: boolean) {
|
||||
try {
|
||||
const payload = {
|
||||
...session,
|
||||
expires: new Date(session.expires).getTime(),
|
||||
};
|
||||
const { payload: result } = await wreck.put(
|
||||
`updateSession?force=${Boolean(force)}`,
|
||||
{
|
||||
payload,
|
||||
}
|
||||
);
|
||||
return result;
|
||||
} catch {
|
||||
throw new Error("UPDATE_SESSION_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionToken: string) {
|
||||
try {
|
||||
await wreck.delete(`deleteSession/${sessionToken}`);
|
||||
} catch {
|
||||
throw new Error("DELETE_SESSION_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createUser,
|
||||
getUser,
|
||||
getUserByEmail,
|
||||
getUserByAccount,
|
||||
updateUser,
|
||||
// deleteUser,
|
||||
linkAccount,
|
||||
// unlinkAccount,
|
||||
createSession,
|
||||
getSessionAndUser,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
// @ts-expect-error: Type error
|
||||
} as AdapterInstance<Profile, User, Session, unknown>;
|
||||
}
|
||||
|
||||
return getAdapter();
|
||||
};
|
||||
30
apps/link/app/(main)/admin/metamigo/_lib/phone-numbers.ts
Normal file
30
apps/link/app/(main)/admin/metamigo/_lib/phone-numbers.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { regex } from "react-admin";
|
||||
|
||||
export const E164Regex = /^\+[1-9]\d{1,14}$/;
|
||||
/**
|
||||
* Returns true if the number is a valid E164 number
|
||||
*/
|
||||
export const isValidE164Number = (phoneNumber: string) =>
|
||||
E164Regex.test(phoneNumber);
|
||||
|
||||
/**
|
||||
* Given a phone number approximation, will clean out whitespace and punctuation.
|
||||
*/
|
||||
export const sanitizeE164Number = (phoneNumber: string) => {
|
||||
if (!phoneNumber) return "";
|
||||
if (!phoneNumber.trim()) return "";
|
||||
const sanitized = phoneNumber
|
||||
.replaceAll(/\s/g, "")
|
||||
.replaceAll(".", "")
|
||||
.replaceAll("-", "")
|
||||
.replaceAll("(", "")
|
||||
.replaceAll(")", "");
|
||||
|
||||
if (sanitized[0] !== "+") return `+${sanitized}`;
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export const validateE164Number = regex(
|
||||
E164Regex,
|
||||
"Must start with a + and have no punctunation and no spaces."
|
||||
);
|
||||
|
|
@ -1,16 +1,5 @@
|
|||
import { Metadata } from "next";
|
||||
import { MetamigoWrapper } from "./_components/MetamigoWrapper";
|
||||
import { Admin } from "./_components/Admin";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Metamigo",
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
params: {
|
||||
path: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function Page({ params: { path } }: PageProps) {
|
||||
return <MetamigoWrapper path={path} />;
|
||||
export default function Page() {
|
||||
return <Admin />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { Grid } from "@mui/material";
|
||||
import Iframe from "react-iframe";
|
||||
|
||||
type LeafcutterWrapperProps = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const LeafcutterWrapper: FC<LeafcutterWrapperProps> = ({ path }) => {
|
||||
const leafcutterURL = `https://lc.digiresilience.org/${path}`;
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
sx={{ height: "100%", width: "100%" }}
|
||||
direction="column"
|
||||
>
|
||||
<Grid item sx={{ height: "100vh", width: "100%" }}>
|
||||
<Iframe
|
||||
id="leafcutter"
|
||||
url={leafcutterURL}
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameBorder={0}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { LeafcutterWrapper } from "./_components/LeafcutterWrapper";
|
||||
|
||||
type PageProps = {
|
||||
params: {
|
||||
view: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function Page({ params: { view } }: PageProps) {
|
||||
return <LeafcutterWrapper path={view} />;
|
||||
}
|
||||
30
apps/link/app/(main)/leafcutter/_components/Home.tsx
Normal file
30
apps/link/app/(main)/leafcutter/_components/Home.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { Home as HomeInternal } from "leafcutter-common";
|
||||
import { fetchLeafcutter } from "@/app/_lib/utils";
|
||||
import ClientOnly from "@/app/(main)/_components/ClientOnly";
|
||||
|
||||
export const Home: FC = () => {
|
||||
const [visualizations, setVisualizations] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const getVisualizations = async () => {
|
||||
const visualizations = await fetchLeafcutter(
|
||||
"https://macmini.tiger-agama.ts.net:3001/api/visualizations/list",
|
||||
{},
|
||||
);
|
||||
if (visualizations) {
|
||||
setVisualizations(visualizations);
|
||||
}
|
||||
};
|
||||
|
||||
getVisualizations();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ClientOnly>
|
||||
<HomeInternal visualizations={visualizations} />
|
||||
</ClientOnly>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { FC, PropsWithChildren } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export const LeafcutterWrapper: FC<PropsWithChildren> = ({ children }) => {
|
||||
return <Box sx={{ p: 3 }}>{children}</Box>;
|
||||
};
|
||||
10
apps/link/app/(main)/leafcutter/about/page.tsx
Normal file
10
apps/link/app/(main)/leafcutter/about/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { About } from "leafcutter-common";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<About />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
13
apps/link/app/(main)/leafcutter/create/page.tsx
Normal file
13
apps/link/app/(main)/leafcutter/create/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// import { getTemplates } from "app/_lib/opensearch";
|
||||
import { Create } from "leafcutter-common";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export default async function Page() {
|
||||
const templates = []; // await getTemplates(100);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Create templates={templates} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
10
apps/link/app/(main)/leafcutter/faq/page.tsx
Normal file
10
apps/link/app/(main)/leafcutter/faq/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { FAQ } from "leafcutter-common";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<FAQ />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
10
apps/link/app/(main)/leafcutter/login/page.tsx
Normal file
10
apps/link/app/(main)/leafcutter/login/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { FAQ } from "leafcutter-common";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<FAQ />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { Home } from "./_components/Home";
|
||||
import { LeafcutterWrapper } from "./_components/LeafcutterWrapper";
|
||||
|
||||
export default function Page() {
|
||||
redirect("/leafcutter/home");
|
||||
export default async function Page() {
|
||||
return (
|
||||
<LeafcutterWrapper>
|
||||
<Home />
|
||||
</LeafcutterWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
12
apps/link/app/(main)/leafcutter/trends/page.tsx
Normal file
12
apps/link/app/(main)/leafcutter/trends/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { Trends } from "leafcutter-common";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Trends visualizations={[]} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
9
apps/link/app/(main)/logout/page.tsx
Normal file
9
apps/link/app/(main)/logout/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
export default function Page() {
|
||||
signOut({ callbackUrl: "/login" });
|
||||
|
||||
return <div />;
|
||||
}
|
||||
11
apps/link/app/(main)/reporting/page.tsx
Normal file
11
apps/link/app/(main)/reporting/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Metadata } from "next";
|
||||
import { ZammadWrapper } from "../../(main)/_components/ZammadWrapper";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Reporting",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <ZammadWrapper path="#report" />;
|
||||
}
|
||||
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { FC, useLayoutEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CircularProgress, Box, Grid } from "@mui/material";
|
||||
import { ZammadWrapper } from "../../../(main)/_components/ZammadWrapper";
|
||||
|
||||
export const Setup: FC = () => {
|
||||
|
|
@ -10,5 +11,21 @@ export const Setup: FC = () => {
|
|||
setTimeout(() => router.push("/"), 4000);
|
||||
}, [router]);
|
||||
|
||||
return <ZammadWrapper path="/auth/sso" hideSidebar={false} />;
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
sx={{ height: 500 }}
|
||||
justifyContent="center"
|
||||
alignContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Grid item>
|
||||
<CircularProgress size={80} color="success" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<ZammadWrapper path="/auth/sso" hideSidebar={false} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ import { SessionProvider } from "next-auth/react";
|
|||
import { NextAppDirEmotionCacheProvider } from "tss-react/next/appDir";
|
||||
import { SWRConfig } from "swr";
|
||||
import { GraphQLClient } from "graphql-request";
|
||||
import { I18n } from "react-polyglot";
|
||||
import { AdapterDateFns } from "@mui/x-date-pickers-pro/AdapterDateFns";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers-pro";
|
||||
import { LicenseInfo } from "@mui/x-date-pickers-pro";
|
||||
import { locales } from "leafcutter-common";
|
||||
|
||||
LicenseInfo.setLicenseKey(
|
||||
"7c9bf25d9e240f76e77cbf7d2ba58a23Tz02NjU4OCxFPTE3MTU4NjIzMzQ2ODgsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI="
|
||||
"7c9bf25d9e240f76e77cbf7d2ba58a23Tz02NjU4OCxFPTE3MTU4NjIzMzQ2ODgsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
|
||||
);
|
||||
|
||||
export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
|
|
@ -21,12 +23,14 @@ export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
|
|||
typeof window !== "undefined" && window.location.origin
|
||||
? window.location.origin
|
||||
: null;
|
||||
const client = new GraphQLClient(`${origin}/proxy/zammad/graphql`, {
|
||||
const client = new GraphQLClient(`${origin}/zammad/graphql`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const messages: any = { en: locales.en, fr: locales.fr };
|
||||
const locale = "en";
|
||||
const graphQLFetcher = async ({ document, variables }: any) => {
|
||||
const requestHeaders = {
|
||||
"X-CSRF-Token": csrfToken,
|
||||
|
|
@ -34,7 +38,7 @@ export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
|
|||
const { data, headers } = await client.rawRequest(
|
||||
document,
|
||||
variables,
|
||||
requestHeaders
|
||||
requestHeaders,
|
||||
);
|
||||
|
||||
const token = headers.get("CSRF-Token");
|
||||
|
|
@ -51,7 +55,9 @@ export const MultiProvider: FC<PropsWithChildren> = ({ children }) => {
|
|||
<SessionProvider>
|
||||
<CookiesProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
{children}
|
||||
<I18n locale={locale} messages={messages[locale]}>
|
||||
{children}
|
||||
</I18n>
|
||||
</LocalizationProvider>
|
||||
</CookiesProvider>
|
||||
</SessionProvider>
|
||||
|
|
|
|||
41
apps/link/app/_lib/utils.ts
Normal file
41
apps/link/app/_lib/utils.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export const fetchLeafcutter = async (url: string, options: any) => {
|
||||
/*
|
||||
|
||||
const headers = {
|
||||
'X-Opensearch-Username': process.env.OPENSEARCH_USER!,
|
||||
'X-Opensearch-Password': process.env.OPENSEARCH_PASSWORD!,
|
||||
'X-Leafcutter-User': token.email.toLowerCase()
|
||||
};
|
||||
*/
|
||||
const fetchData = async (url: string, options: any) => {
|
||||
try {
|
||||
const res = await fetch(url, options);
|
||||
const json = await res.json();
|
||||
return json;
|
||||
} catch (error) {
|
||||
console.log({ error });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const data = await fetchData(url, options);
|
||||
console.log({ data });
|
||||
|
||||
if (!data) {
|
||||
const csrfURL = `${process.env.NEXT_PUBLIC_LEAFCUTTER_URL}/api/auth/csrf`;
|
||||
const csrfData = await fetchData(csrfURL, {});
|
||||
console.log({ csrfData });
|
||||
const authURL = `${process.env.NEXT_PUBLIC_LEAFCUTTER_URL}/api/auth/callback/credentials`;
|
||||
const authData = await fetchData(authURL, { method: "POST" });
|
||||
console.log({ authData });
|
||||
if (!authData) {
|
||||
return null;
|
||||
} else {
|
||||
return await fetchData(url, options);
|
||||
}
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -1,21 +1,111 @@
|
|||
import NextAuth from "next-auth";
|
||||
import Google from "next-auth/providers/google";
|
||||
// import Apple from "next-auth/providers/apple";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import Apple from "next-auth/providers/apple";
|
||||
|
||||
const headers = { Authorization: `Token ${process.env.ZAMMAD_API_TOKEN}` };
|
||||
|
||||
const fetchRoles = async () => {
|
||||
const url = `${process.env.ZAMMAD_URL}/api/v1/roles`;
|
||||
const res = await fetch(url, { headers });
|
||||
const roles = await res.json();
|
||||
const formattedRoles = roles.reduce((acc: any, role: any) => {
|
||||
acc[role.id] = role.name;
|
||||
return acc;
|
||||
}, {});
|
||||
return formattedRoles;
|
||||
};
|
||||
|
||||
const fetchUser = async (email: string) => {
|
||||
const url = `${process.env.ZAMMAD_URL}/api/v1/users/search?query=email:${email}&limit=1`;
|
||||
const res = await fetch(url, { headers });
|
||||
const users = await res.json();
|
||||
const user = users?.[0];
|
||||
return user;
|
||||
};
|
||||
|
||||
const getUserRoles = async (email: string) => {
|
||||
const user = await fetchUser(email);
|
||||
const allRoles = await fetchRoles();
|
||||
const roles = user.role_ids.map((roleID: number) => {
|
||||
const role = allRoles[roleID];
|
||||
return role ? role.toLowerCase().replace(" ", "_") : null;
|
||||
});
|
||||
return roles.filter((role: string) => role !== null);
|
||||
};
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
console.log({ email, password });
|
||||
const url = `${process.env.ZAMMAD_URL}/api/v1/users/me`;
|
||||
const authorization = 'Basic ' + Buffer.from(email + ":" + password).toString('base64');
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
authorization
|
||||
}
|
||||
});
|
||||
const user = await res.json();
|
||||
console.log({ user });
|
||||
|
||||
if (user && !user.error && user.id) {
|
||||
return user;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handler = NextAuth({
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
error: "/login",
|
||||
signOut: "/logout",
|
||||
},
|
||||
providers: [
|
||||
Google({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
}),
|
||||
/*
|
||||
Apple({
|
||||
clientId: process.env.APPLE_CLIENT_ID,
|
||||
clientSecret: process.env.APPLE_CLIENT_SECRET
|
||||
}),
|
||||
*/
|
||||
Credentials({
|
||||
name: "Zammad",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "text", },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
const user = await login(credentials.email, credentials.password);
|
||||
if (user) {
|
||||
return user;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
|
||||
callbacks: {
|
||||
signIn: async ({ user, account, profile }) => {
|
||||
const roles = await getUserRoles(user.email);
|
||||
return roles.includes("admin") || roles.includes("agent");
|
||||
},
|
||||
session: async ({ session, user, token }) => {
|
||||
// @ts-ignore
|
||||
session.user.roles = token.roles;
|
||||
// @ts-ignore
|
||||
session.user.leafcutter = token.leafcutter;
|
||||
return session;
|
||||
},
|
||||
jwt: async ({ token, user, account, profile, trigger }) => {
|
||||
if (user) {
|
||||
token.roles = await getUserRoles(user.email);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const handler = (req: NextRequest) => {
|
||||
NextResponse.redirect('/proxy/zammad/api/v1' + req.url.substring('/api/v1'.length));
|
||||
NextResponse.redirect('/zammad/api/v1' + req.url.substring('/api/v1'.length));
|
||||
};
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue