Add all repos
This commit is contained in:
parent
faa12c60bc
commit
8a91c9b89b
369 changed files with 29047 additions and 28 deletions
7
metamigo-frontend/.eslintignore
Normal file
7
metamigo-frontend/.eslintignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
**/dist
|
||||
/data/schema.graphql
|
||||
/data/schema.sql
|
||||
/graphql/index.*
|
||||
/client/.next
|
||||
.next
|
||||
3
metamigo-frontend/.eslintrc
Normal file
3
metamigo-frontend/.eslintrc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next"
|
||||
}
|
||||
85
metamigo-frontend/components/AdminLogin.tsx
Normal file
85
metamigo-frontend/components/AdminLogin.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { FC, useEffect } from "react";
|
||||
import { CircularProgress, Typography, Grid } from "@material-ui/core";
|
||||
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();
|
||||
},
|
||||
logout: async () => {
|
||||
const session = await getSession();
|
||||
if (session) {
|
||||
await signOut();
|
||||
}
|
||||
},
|
||||
checkError: (e: any) => {
|
||||
if (e.graphQLErrors && e.graphQLErrors.length > 0) {
|
||||
const permDenied =
|
||||
e.graphQLErrors.filter((e: any) =>
|
||||
e.message.match(/.*permission denied.*/)
|
||||
).length > 0;
|
||||
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();
|
||||
}
|
||||
},
|
||||
checkAuth: async () => {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
getIdentity: async () => {
|
||||
const session = await getSession();
|
||||
if (!session) return Promise.reject(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) {
|
||||
signIn();
|
||||
} else {
|
||||
reactAdminLogin({ ok: true });
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={5}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justify="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>
|
||||
);
|
||||
};
|
||||
20
metamigo-frontend/components/Auth.tsx
Normal file
20
metamigo-frontend/components/Auth.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { FC, PropsWithChildren, useEffect } from "react";
|
||||
import { CircularProgress } from "@material-ui/core";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export const Auth: FC<PropsWithChildren> = ({ children }) => {
|
||||
const router = useRouter();
|
||||
const { data: session, status: loading } = useSession();
|
||||
useEffect(() => {
|
||||
if (!session && !loading) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [session, loading]);
|
||||
|
||||
if (loading) {
|
||||
return <CircularProgress />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
60
metamigo-frontend/components/DigitInput/index.tsx
Normal file
60
metamigo-frontend/components/DigitInput/index.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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) => {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
aria-label="verification code"
|
||||
className={styles.input}
|
||||
{...props}
|
||||
ref={ref}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const DigitSeparator = forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<InputAttributes, "ref"> & {
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<>
|
||||
<span className={styles.hyphen} ref={ref} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const SixDigitInput = ({ value, onChange }: any) => {
|
||||
const digits = useDigitInput({
|
||||
acceptedCharacters: /^[0-9]$/,
|
||||
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>
|
||||
);
|
||||
};
|
||||
64
metamigo-frontend/components/MetamigoAdmin.tsx
Normal file
64
metamigo-frontend/components/MetamigoAdmin.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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, createMuiTheme } from "@material-ui/core/styles";
|
||||
import { metamigoDataProvider } from "../lib/dataprovider";
|
||||
import { theme } from "./layout/themes";
|
||||
import { Layout } from "./layout";
|
||||
import englishMessages from "../i18n/en";
|
||||
import users from "./users";
|
||||
import accounts from "./accounts";
|
||||
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) => {
|
||||
return englishMessages;
|
||||
}, "en");
|
||||
|
||||
const MetamigoAdmin: FC = () => {
|
||||
const [dataProvider, setDataProvider] = useState(null);
|
||||
const client = useApolloClient();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const dataProvider = await metamigoDataProvider(client);
|
||||
// @ts-ignore
|
||||
setDataProvider(() => dataProvider);
|
||||
})();
|
||||
}, [client]);
|
||||
return (
|
||||
dataProvider && (
|
||||
<ThemeProvider theme={createMuiTheme(theme)}>
|
||||
<Admin
|
||||
disableTelemetry
|
||||
dataProvider={dataProvider}
|
||||
layout={Layout}
|
||||
i18nProvider={i18nProvider}
|
||||
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="users" {...users} />
|
||||
<Resource name="accounts" {...accounts} />
|
||||
<Resource name="languages" />
|
||||
</Admin>
|
||||
</ThemeProvider>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default MetamigoAdmin;
|
||||
59
metamigo-frontend/components/accounts/AccountEdit.tsx
Normal file
59
metamigo-frontend/components/accounts/AccountEdit.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { FC } from "react";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import {
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
Edit,
|
||||
ReferenceInput,
|
||||
SelectInput,
|
||||
DateInput,
|
||||
Toolbar,
|
||||
DeleteButton,
|
||||
EditProps,
|
||||
} from "react-admin";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const useStyles = makeStyles((_theme) => ({
|
||||
defaultToolbar: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
}));
|
||||
|
||||
type AccountEditToolbarProps = {
|
||||
record?: any;
|
||||
};
|
||||
|
||||
const AccountEditToolbar: FC<AccountEditToolbarProps> = (props) => {
|
||||
const { data: session } = useSession();
|
||||
const classes = 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.name : record.email;
|
||||
return <span>Account {title}</span>;
|
||||
};
|
||||
|
||||
export const AccountEdit = (props: EditProps) => (
|
||||
<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;
|
||||
43
metamigo-frontend/components/accounts/AccountList.tsx
Normal file
43
metamigo-frontend/components/accounts/AccountList.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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 (
|
||||
<DeleteButton
|
||||
disabled={session?.user?.email === props.record.userId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountList = (props: ListProps) => (
|
||||
<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;
|
||||
10
metamigo-frontend/components/accounts/index.ts
Normal file
10
metamigo-frontend/components/accounts/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import AccountIcon from "@material-ui/icons/AccountTree";
|
||||
import AccountList from "./AccountList";
|
||||
import AccountEdit from "./AccountEdit";
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default {
|
||||
list: AccountList,
|
||||
edit: AccountEdit,
|
||||
icon: AccountIcon,
|
||||
};
|
||||
54
metamigo-frontend/components/layout/AppBar.tsx
Normal file
54
metamigo-frontend/components/layout/AppBar.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { forwardRef } from "react";
|
||||
import { AppBar, UserMenu, MenuItemLink, useTranslate } from "react-admin";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import SettingsIcon from "@material-ui/icons/Settings";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
title: {
|
||||
flex: 1,
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
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 CustomUserMenu = (props: any) => (
|
||||
<UserMenu {...props}>
|
||||
<ConfigurationMenu />
|
||||
</UserMenu>
|
||||
);
|
||||
|
||||
const CustomAppBar = (props: any) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<AppBar {...props} elevation={1} userMenu={<CustomUserMenu />}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="inherit"
|
||||
className={classes.title}
|
||||
id="react-admin-title"
|
||||
/>
|
||||
<span className={classes.spacer} />
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomAppBar;
|
||||
21
metamigo-frontend/components/layout/Layout.tsx
Normal file
21
metamigo-frontend/components/layout/Layout.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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} />;
|
||||
|
||||
const Layout = (props: LayoutProps) => {
|
||||
return (
|
||||
<RaLayout
|
||||
{...props}
|
||||
appBar={AppBar}
|
||||
menu={Menu}
|
||||
sidebar={CustomSidebar}
|
||||
// @ts-ignore
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
106
metamigo-frontend/components/layout/Logo.tsx
Normal file
106
metamigo-frontend/components/layout/Logo.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { SVGProps } from "react";
|
||||
|
||||
const Logo = (props: SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<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;
|
||||
133
metamigo-frontend/components/layout/Menu.tsx
Normal file
133
metamigo-frontend/components/layout/Menu.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/* eslint-disable camelcase */
|
||||
import { FC, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import SecurityIcon from "@material-ui/icons/Security";
|
||||
import VoiceIcon from "@material-ui/icons/PhoneInTalk";
|
||||
import { Box } from "@material-ui/core";
|
||||
import { useTheme } from "@material-ui/core/styles";
|
||||
import useMediaQuery from "@material-ui/core/useMediaQuery";
|
||||
import { useTranslate, MenuItemLink, MenuProps } from "react-admin";
|
||||
import users from "../users";
|
||||
import accounts from "../accounts";
|
||||
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 = useTheme();
|
||||
const isXSmall = useMediaQuery(theme.breakpoints.down("xs"));
|
||||
const open = useSelector((state: any) => state.admin.ui.sidebarOpen);
|
||||
useSelector((state: any) => state.theme); // force rerender on theme change
|
||||
|
||||
const handleToggle = (menu: MenuName) => {
|
||||
setState((state) => ({ ...state, [menu]: !state[menu] }));
|
||||
};
|
||||
|
||||
return <div />;
|
||||
};
|
||||
/*
|
||||
<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}
|
||||
/>
|
||||
<SubMenu
|
||||
handleToggle={() => handleToggle("menuSecurity")}
|
||||
isOpen={state.menuSecurity}
|
||||
sidebarIsOpen={open}
|
||||
name="pos.menu.security"
|
||||
icon={<SecurityIcon />}
|
||||
dense={dense}
|
||||
>
|
||||
<MenuItemLink
|
||||
to={`/users`}
|
||||
primaryText={translate(`resources.users.name`, {
|
||||
smart_count: 2,
|
||||
})}
|
||||
leftIcon={<users.icon />}
|
||||
onClick={onMenuClick}
|
||||
sidebarIsOpen={open}
|
||||
dense={dense}
|
||||
/>
|
||||
<MenuItemLink
|
||||
to={`/accounts`}
|
||||
primaryText={translate(`resources.accounts.name`, {
|
||||
smart_count: 2,
|
||||
})}
|
||||
leftIcon={<accounts.icon />}
|
||||
onClick={onMenuClick}
|
||||
sidebarIsOpen={open}
|
||||
dense={dense}
|
||||
/>
|
||||
</SubMenu>
|
||||
{isXSmall && logout}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
*/
|
||||
export default Menu;
|
||||
83
metamigo-frontend/components/layout/SubMenu.tsx
Normal file
83
metamigo-frontend/components/layout/SubMenu.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { FC, PropsWithChildren, Fragment, ReactElement } from "react";
|
||||
import ExpandMore from "@material-ui/icons/ExpandMore";
|
||||
import List from "@material-ui/core/List";
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
import ListItemIcon from "@material-ui/core/ListItemIcon";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import Collapse from "@material-ui/core/Collapse";
|
||||
import Tooltip from "@material-ui/core/Tooltip";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import { useTranslate } from "react-admin";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
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,
|
||||
}) => {
|
||||
const translate = useTranslate();
|
||||
const classes = useStyles();
|
||||
|
||||
const header = (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
5
metamigo-frontend/components/layout/index.ts
Normal file
5
metamigo-frontend/components/layout/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import AppBar from "./AppBar";
|
||||
import Layout from "./Layout";
|
||||
import Menu from "./Menu";
|
||||
|
||||
export { AppBar, Layout, Menu };
|
||||
71
metamigo-frontend/components/layout/themes.ts
Normal file
71
metamigo-frontend/components/layout/themes.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
export const theme = {
|
||||
palette: {
|
||||
primary: {
|
||||
main: "#337799",
|
||||
},
|
||||
secondary: {
|
||||
light: "#5f5fc4",
|
||||
main: "#283593",
|
||||
dark: "#001064",
|
||||
contrastText: "#fff",
|
||||
},
|
||||
background: {
|
||||
default: "#fff",
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 5,
|
||||
},
|
||||
typography: {
|
||||
h6: { fontSize: 16, fontWeight: 600, color: "#1bb1bb" },
|
||||
},
|
||||
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)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
27
metamigo-frontend/components/signal/bots/Digits.module.css
Normal file
27
metamigo-frontend/components/signal/bots/Digits.module.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
34
metamigo-frontend/components/signal/bots/SignalBotCreate.tsx
Normal file
34
metamigo-frontend/components/signal/bots/SignalBotCreate.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
SimpleForm,
|
||||
Create,
|
||||
TextInput,
|
||||
required,
|
||||
CreateProps,
|
||||
} from "react-admin";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { validateE164Number } from "../../../lib/phone-numbers";
|
||||
|
||||
const SignalBotCreate = (props: CreateProps) => {
|
||||
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;
|
||||
12
metamigo-frontend/components/signal/bots/SignalBotEdit.tsx
Normal file
12
metamigo-frontend/components/signal/bots/SignalBotEdit.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { SimpleForm, Edit, TextInput, required, EditProps } from "react-admin";
|
||||
|
||||
const SignalBotEdit = (props: EditProps) => (
|
||||
<Edit {...props} title="Edit Bot">
|
||||
<SimpleForm>
|
||||
<TextInput disabled source="phoneNumber" validate={[required()]} />
|
||||
<TextInput source="description" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
|
||||
export default SignalBotEdit;
|
||||
23
metamigo-frontend/components/signal/bots/SignalBotList.tsx
Normal file
23
metamigo-frontend/components/signal/bots/SignalBotList.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import {
|
||||
List,
|
||||
Datagrid,
|
||||
DateField,
|
||||
TextField,
|
||||
BooleanField,
|
||||
ListProps,
|
||||
} from "react-admin";
|
||||
|
||||
const SignalBotList = (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 SignalBotList;
|
||||
474
metamigo-frontend/components/signal/bots/SignalBotShow.tsx
Normal file
474
metamigo-frontend/components/signal/bots/SignalBotShow.tsx
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
import React, { 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 "@material-ui/core";
|
||||
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
|
||||
rows={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,
|
||||
onFailure,
|
||||
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",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Failed to request verification code:", error);
|
||||
}
|
||||
|
||||
if (response && response.ok) {
|
||||
onSuccess();
|
||||
} else {
|
||||
onFailure(response.status || 400);
|
||||
}
|
||||
};
|
||||
|
||||
const VerificationCodeRequest = ({
|
||||
verifyMode,
|
||||
data,
|
||||
onSuccess,
|
||||
onFailure,
|
||||
}: any) => {
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
await handleRequestCode({
|
||||
verifyMode,
|
||||
id: data.id,
|
||||
onSuccess,
|
||||
onFailure,
|
||||
});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
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,
|
||||
onFailure,
|
||||
handleClose,
|
||||
}: any) => {
|
||||
const [code, setCode] = React.useState(undefined);
|
||||
const [isSubmitting, setSubmitting] = React.useState(false);
|
||||
|
||||
const handleSubmitVerification = async () => {
|
||||
setSubmitting(true);
|
||||
await handleRequestCode({
|
||||
verifyMode,
|
||||
id: data.id,
|
||||
onSuccess,
|
||||
onFailure,
|
||||
captchaCode: code,
|
||||
});
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleCaptchaChange = (value) => {
|
||||
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,
|
||||
}) => {
|
||||
const [code, setValue] = React.useState("");
|
||||
const [isSubmitting, setSubmitting] = React.useState(false);
|
||||
const [isValid, setValid] = React.useState(false);
|
||||
const [submissionError, setSubmissionError] = React.useState(undefined);
|
||||
const translate = useTranslate();
|
||||
|
||||
const validator = (v) => v.trim().length === 6;
|
||||
|
||||
const handleValueChange = (newValue) => {
|
||||
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) => {
|
||||
const [stage, setStage] = React.useState("request");
|
||||
const onRequestSuccess = () => setStage("verify");
|
||||
const onRestartVerification = () => setStage("request");
|
||||
const handleClose = () => {
|
||||
setStage("request");
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const onFailure = (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}
|
||||
onFailure={onFailure}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
{props.open && stage === "verify" && (
|
||||
<VerificationCodeInput
|
||||
{...props}
|
||||
handleRestartVerification={onRestartVerification}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
{props.open && stage === "captcha" && (
|
||||
<VerificationCaptcha
|
||||
mode={props.verifyMode}
|
||||
onSuccess={onRequestSuccess}
|
||||
onFailure={onRestartVerification}
|
||||
handleClose={handleClose}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const SignalBotShowActions = ({ basePath, data }) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [verifyMode, setVerifyMode] = React.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 basePath={basePath} 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 = (props: ShowProps) => (
|
||||
<Show
|
||||
// @ts-expect-error: Missing props
|
||||
actions={<SignalBotShowActions />}
|
||||
{...props}
|
||||
title="Signal Bot"
|
||||
// @ts-expect-error: Missing props
|
||||
aside={<Sidebar />}
|
||||
>
|
||||
<SimpleShowLayout>
|
||||
<TextField source="phoneNumber" />
|
||||
<BooleanField source="isVerified" />
|
||||
<TextField source="description" />
|
||||
<TextField source="token" />
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
);
|
||||
|
||||
export default SignalBotShow;
|
||||
14
metamigo-frontend/components/signal/bots/index.ts
Normal file
14
metamigo-frontend/components/signal/bots/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import SignalBotIcon from "@material-ui/icons/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,
|
||||
};
|
||||
24
metamigo-frontend/components/signal/bots/shared.tsx
Normal file
24
metamigo-frontend/components/signal/bots/shared.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import {
|
||||
SelectInput,
|
||||
required,
|
||||
ReferenceInput,
|
||||
ReferenceField,
|
||||
TextField,
|
||||
} from "react-admin";
|
||||
|
||||
export const SignalBotSelectInput = (source: string) => () => (
|
||||
<ReferenceInput
|
||||
label="Signal Bot"
|
||||
source={source}
|
||||
reference="signalBots"
|
||||
validate={[required()]}
|
||||
>
|
||||
<SelectInput optionText="phoneNumber" />
|
||||
</ReferenceInput>
|
||||
);
|
||||
|
||||
export const SignalBotField = (source: string) => () => (
|
||||
<ReferenceField label="Signal Bot" reference="signalBots" source={source}>
|
||||
<TextField source="phoneNumber" />
|
||||
</ReferenceField>
|
||||
);
|
||||
27
metamigo-frontend/components/users/UserCreate.tsx
Normal file
27
metamigo-frontend/components/users/UserCreate.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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> = (props) => {
|
||||
const { data: session } = useSession();
|
||||
return (
|
||||
<Create {...props} 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;
|
||||
64
metamigo-frontend/components/users/UserEdit.tsx
Normal file
64
metamigo-frontend/components/users/UserEdit.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import {
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
BooleanInput,
|
||||
DateInput,
|
||||
Edit,
|
||||
Toolbar,
|
||||
SaveButton,
|
||||
DeleteButton,
|
||||
EditProps,
|
||||
useRedirect,
|
||||
} from "react-admin";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { UserRoleInput } from "./shared";
|
||||
|
||||
const useStyles = makeStyles((_theme) => ({
|
||||
defaultToolbar: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
}));
|
||||
|
||||
const UserEditToolbar = (props) => {
|
||||
const classes = useStyles(props);
|
||||
const redirect = useRedirect();
|
||||
|
||||
return (
|
||||
<Toolbar className={classes.defaultToolbar} {...props}>
|
||||
<SaveButton
|
||||
label="save"
|
||||
mutationOptions={{ onSuccess: (response) => redirect("/users") }}
|
||||
/>
|
||||
<DeleteButton disabled={props.session.user.id === props.record.id} />
|
||||
</Toolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const UserTitle = ({ record }: { record?: any }) => {
|
||||
let title = "";
|
||||
if (record) title = record.name ? record.name : record.email;
|
||||
return <span>User {title}</span>;
|
||||
};
|
||||
|
||||
const UserEdit = (props: EditProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<Edit title={<UserTitle />} {...props}>
|
||||
<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;
|
||||
28
metamigo-frontend/components/users/UserList.tsx
Normal file
28
metamigo-frontend/components/users/UserList.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import {
|
||||
List,
|
||||
Datagrid,
|
||||
ImageField,
|
||||
DateField,
|
||||
TextField,
|
||||
EmailField,
|
||||
BooleanField,
|
||||
ListProps,
|
||||
} from "react-admin";
|
||||
|
||||
const UserList = (props: ListProps) => (
|
||||
<List {...props} 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;
|
||||
12
metamigo-frontend/components/users/index.ts
Normal file
12
metamigo-frontend/components/users/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import UserIcon from "@material-ui/icons/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,
|
||||
};
|
||||
14
metamigo-frontend/components/users/shared.tsx
Normal file
14
metamigo-frontend/components/users/shared.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { SelectInput } from "react-admin";
|
||||
|
||||
export const UserRoleInput = (props) => (
|
||||
<SelectInput
|
||||
source="userRole"
|
||||
choices={[
|
||||
{ id: "NONE", name: "None" },
|
||||
{ id: "USER", name: "User" },
|
||||
{ id: "ADMIN", name: "Admin" },
|
||||
]}
|
||||
disabled={props.session.user.id === props.record.id}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
Create,
|
||||
PasswordInput,
|
||||
CreateProps,
|
||||
} from "react-admin";
|
||||
import { ProviderKindInput } from "./shared";
|
||||
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
|
||||
const TwilioCredentialsInput = () => (
|
||||
<span>
|
||||
<TextField name="accountSid" label="Account Sid" />
|
||||
<TextField name="authToken" label="Auth Token" />
|
||||
</span>
|
||||
);
|
||||
|
||||
const ProviderCreate = (props: CreateProps) => {
|
||||
return (
|
||||
<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 @@
|
|||
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.name : record.email;
|
||||
return <span>Provider {title}</span>;
|
||||
};
|
||||
|
||||
const ProviderEdit = (props: EditProps) => {
|
||||
return (
|
||||
<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,14 @@
|
|||
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;
|
||||
11
metamigo-frontend/components/voice/providers/index.ts
Normal file
11
metamigo-frontend/components/voice/providers/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import ProviderIcon from "@material-ui/icons/Business";
|
||||
import ProviderList from "./ProviderList";
|
||||
import ProviderEdit from "./ProviderEdit";
|
||||
import ProviderCreate from "./ProviderCreate";
|
||||
|
||||
export default {
|
||||
list: ProviderList,
|
||||
create: ProviderCreate,
|
||||
edit: ProviderEdit,
|
||||
icon: ProviderIcon,
|
||||
};
|
||||
9
metamigo-frontend/components/voice/providers/shared.tsx
Normal file
9
metamigo-frontend/components/voice/providers/shared.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { SelectInput } from "react-admin";
|
||||
|
||||
export const ProviderKindInput = (props) => (
|
||||
<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;
|
||||
}
|
||||
147
metamigo-frontend/components/voice/voicelines/MicInput.tsx
Normal file
147
metamigo-frontend/components/voice/voicelines/MicInput.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { useInput } from "react-admin";
|
||||
import React, { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import MicIcon from "@material-ui/icons/Mic";
|
||||
import StopIcon from "@material-ui/icons/Stop";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import { makeStyles, useTheme } from "@material-ui/core/styles";
|
||||
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<ReactMicProps>(
|
||||
// eslint-disable-next-line promise/prefer-await-to-then
|
||||
() => import("react-mic").then((mod) => mod.ReactMic),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const blobToDataUri = (blob) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
return new Promise((resolve) => {
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const dataUriToObj = (dataUri) => {
|
||||
const [prefix, base64] = dataUri.split(",");
|
||||
const mime = prefix.slice(5, prefix.indexOf(";"));
|
||||
|
||||
const result = {};
|
||||
result[mime] = base64;
|
||||
return result;
|
||||
};
|
||||
|
||||
const blobToResult = async (blob) => {
|
||||
const result = dataUriToObj(await blobToDataUri(blob));
|
||||
return result;
|
||||
};
|
||||
|
||||
const resultToDataUri = (result): string => {
|
||||
if (!result || !result["audio/webm"]) return "";
|
||||
const base64 = result["audio/webm"];
|
||||
const r = `data:audio/webm;base64,${base64}`;
|
||||
return r;
|
||||
};
|
||||
|
||||
const MicInput = (props) => {
|
||||
const { seconds, minutes, hours, start, reset, pause } = useStopwatch();
|
||||
const theme = useTheme();
|
||||
const {
|
||||
input: { value, onChange },
|
||||
} = useInput(props);
|
||||
|
||||
let [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) {}
|
||||
|
||||
async function onStop(recordedBlob) {
|
||||
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((theme) => {
|
||||
return {
|
||||
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}
|
||||
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 @@
|
|||
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) => {
|
||||
return (
|
||||
<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 @@
|
|||
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.name : record.email;
|
||||
return <span>VoiceLine {title}</span>;
|
||||
};
|
||||
|
||||
const VoiceLineEdit = (props: EditProps) => {
|
||||
return (
|
||||
<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,30 @@
|
|||
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) => `${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;
|
||||
12
metamigo-frontend/components/voice/voicelines/index.ts
Normal file
12
metamigo-frontend/components/voice/voicelines/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import VoiceLineIcon from "@material-ui/icons/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;
|
||||
}
|
||||
296
metamigo-frontend/components/voice/voicelines/shared.tsx
Normal file
296
metamigo-frontend/components/voice/voicelines/shared.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import PlayIcon from "@material-ui/icons/PlayCircleFilled";
|
||||
import {
|
||||
TextInput,
|
||||
SelectInput,
|
||||
required,
|
||||
useTranslate,
|
||||
useNotify,
|
||||
ReferenceInput,
|
||||
ReferenceField,
|
||||
TextField,
|
||||
} from "react-admin";
|
||||
import { IconButton, CircularProgress } from "@material-ui/core";
|
||||
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): 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.Device;
|
||||
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", function (device) {
|
||||
device.connect({ language, voice, prompt });
|
||||
});
|
||||
device.on("disconnect", () => resolve());
|
||||
device.on("error", () => resolve());
|
||||
});
|
||||
};
|
||||
|
||||
export const TextToSpeechButton = ({ form }) => {
|
||||
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({
|
||||
func: async () => {
|
||||
setLoading(true);
|
||||
if (ttsProvider) await ttsProvider.provider(voice, language, prompt);
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
})();
|
||||
}, [prompt, language, voice, 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, ...rest) => {
|
||||
return (
|
||||
<TextInput
|
||||
source="promptText"
|
||||
multiline
|
||||
options={{ fullWidth: true }}
|
||||
InputProps={{ endAdornment: <TextToSpeechButton form={form} /> }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const validateVoice = (args, values) => {
|
||||
if (!values.language) return "validation.language";
|
||||
if (!values.voice) return "validation.voice";
|
||||
|
||||
const availableVoices = TwilioLanguages.voices[values.language];
|
||||
const found =
|
||||
availableVoices.filter((v) => v.id === values.voice).length === 1;
|
||||
if (!found) return "validation.voice";
|
||||
};
|
||||
|
||||
export const VoiceInput = (form, ...rest) => {
|
||||
const voice = TwilioLanguages.voices[form.formData.language] || [];
|
||||
return (
|
||||
<SelectInput
|
||||
source="voice"
|
||||
choices={voice}
|
||||
validate={[required(), validateVoice]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
let noAvailableNumbers = false;
|
||||
let availableNumbers = [];
|
||||
|
||||
const getAvailableNumbers = async (providerId) => {
|
||||
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) => {
|
||||
return availableNumbers
|
||||
.filter(({ id }) => id === sid)
|
||||
.map(({ name }) => name)[0];
|
||||
};
|
||||
|
||||
export const populateNumber = (data) => {
|
||||
return {
|
||||
...data,
|
||||
number: sidToNumber(data.providerLineSid),
|
||||
};
|
||||
};
|
||||
|
||||
const hasNumbers = (args, value, values, translate, ...props) => {
|
||||
if (noAvailableNumbers) return "validation.noAvailableNumbers";
|
||||
};
|
||||
|
||||
export const AvailableNumbersInput = (form, ...rest) => {
|
||||
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", "error");
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form && form.formData ? form.formData.providerId : undefined]);
|
||||
|
||||
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;
|
||||
56
metamigo-frontend/components/webhooks/WebhookCreate.tsx
Normal file
56
metamigo-frontend/components/webhooks/WebhookCreate.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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) => {
|
||||
return (
|
||||
<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;
|
||||
52
metamigo-frontend/components/webhooks/WebhookEdit.tsx
Normal file
52
metamigo-frontend/components/webhooks/WebhookEdit.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
Edit,
|
||||
ArrayInput,
|
||||
SimpleFormIterator,
|
||||
regex,
|
||||
required,
|
||||
EditProps,
|
||||
FormDataConsumer,
|
||||
} from "react-admin";
|
||||
import { BackendTypeInput, BackendIdInput, HttpMethodInput } from "./shared";
|
||||
|
||||
const WebhookTitle = ({ record }) => {
|
||||
let title = "";
|
||||
if (record) title = record.name ? record.name : record.email;
|
||||
return <span>Webhook {title}</span>;
|
||||
};
|
||||
|
||||
const WebhookEdit = (props: EditProps) => {
|
||||
return (
|
||||
// @ts-expect-error: Missing props
|
||||
<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;
|
||||
23
metamigo-frontend/components/webhooks/WebhookList.tsx
Normal file
23
metamigo-frontend/components/webhooks/WebhookList.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import {
|
||||
List,
|
||||
Datagrid,
|
||||
DateField,
|
||||
TextField,
|
||||
ReferenceField,
|
||||
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;
|
||||
12
metamigo-frontend/components/webhooks/index.ts
Normal file
12
metamigo-frontend/components/webhooks/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import WebhookIcon from "@material-ui/icons/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,
|
||||
};
|
||||
68
metamigo-frontend/components/webhooks/shared.tsx
Normal file
68
metamigo-frontend/components/webhooks/shared.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
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) => (
|
||||
<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) => (
|
||||
<SelectInput
|
||||
source="backendType"
|
||||
choices={backendChoices}
|
||||
validate={[required()]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const BackendIdInput = (form, ...rest) => {
|
||||
const Component = form.formData.backendType
|
||||
? backendInputComponents[form.formData.backendType]
|
||||
: false;
|
||||
return <>{Component && <Component form={form} {...rest} />}</>;
|
||||
};
|
||||
|
||||
export const BackendIdField = (form, ...rest) => {
|
||||
console.log(form);
|
||||
|
||||
const Component = form.record.backendType
|
||||
? backendFieldComponents[form.record.backendType]
|
||||
: false;
|
||||
return <>{Component && <Component form={form} {...rest} />}</>;
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { List, Datagrid, TextField } from "react-admin";
|
||||
|
||||
const WhatsappAttachmentList = (props) => (
|
||||
<List {...props} exporter={false}>
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="id" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export default WhatsappAttachmentList;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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;
|
||||
10
metamigo-frontend/components/whatsapp/attachments/index.ts
Normal file
10
metamigo-frontend/components/whatsapp/attachments/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import WhatsappAttachmentIcon from "@material-ui/icons/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,38 @@
|
|||
// import dynamic from "next/dynamic";
|
||||
import { SimpleForm, Create, TextInput, required } from "react-admin";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { validateE164Number } from "../../../lib/phone-numbers";
|
||||
|
||||
const WhatsappBotCreate = (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,12 @@
|
|||
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,22 @@
|
|||
import {
|
||||
List,
|
||||
Datagrid,
|
||||
DateField,
|
||||
TextField,
|
||||
BooleanField,
|
||||
} from "react-admin";
|
||||
|
||||
const WhatsappBotList = (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 WhatsappBotList;
|
||||
177
metamigo-frontend/components/whatsapp/bots/WhatsappBotShow.tsx
Normal file
177
metamigo-frontend/components/whatsapp/bots/WhatsappBotShow.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Typography,
|
||||
Grid,
|
||||
Button,
|
||||
TextField as MaterialTextField,
|
||||
IconButton,
|
||||
} from "@material-ui/core";
|
||||
import {
|
||||
Show,
|
||||
SimpleShowLayout,
|
||||
TextField,
|
||||
ShowProps,
|
||||
useQuery,
|
||||
useMutation,
|
||||
useRefresh,
|
||||
BooleanField,
|
||||
} from "react-admin";
|
||||
import QRCode from "react-qr-code";
|
||||
import useSWR from "swr";
|
||||
import RefreshIcon from "@material-ui/icons/Refresh";
|
||||
|
||||
const Sidebar = ({ record }) => {
|
||||
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) => (
|
||||
<Grid 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 } = useQuery({
|
||||
type: "getOne",
|
||||
resource: "whatsappBots",
|
||||
payload: { id: props.id },
|
||||
});
|
||||
const [unverify] = useMutation({
|
||||
type: "update",
|
||||
resource: "whatsappBots",
|
||||
payload: {
|
||||
id: props.id,
|
||||
data: { isVerified: false, qrCode: null, authInfo: null },
|
||||
},
|
||||
});
|
||||
|
||||
const { data: registerData, error: registerError } = useSWR(
|
||||
data && !data?.isVerified
|
||||
? `/api/v1/whatsapp/bots/${props.id}/register`
|
||||
: undefined,
|
||||
{ refreshInterval: 59000 }
|
||||
);
|
||||
|
||||
console.log({ registerData, registerError });
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !data?.isVerified) {
|
||||
const interval = setInterval(() => {
|
||||
refresh();
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [refresh, data]);
|
||||
|
||||
return (
|
||||
// @ts-expect-error: Missing props
|
||||
<Show {...props} title="Bot" aside={<Sidebar />}>
|
||||
<SimpleShowLayout>
|
||||
<TextField source="phoneNumber" />
|
||||
<BooleanField source="isVerified" />
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
style={{ color: "black", backgroundColor: "#ddd" }}
|
||||
onClick={unverify}
|
||||
>
|
||||
Unverify
|
||||
</Button>
|
||||
<TextField source="description" />
|
||||
<TextField source="token" />
|
||||
{!data?.isVerified && data?.qrCode && data?.qrCode !== "" && (
|
||||
<QRCode value={data.qrCode} />
|
||||
)}
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatsappBotShow;
|
||||
14
metamigo-frontend/components/whatsapp/bots/index.ts
Normal file
14
metamigo-frontend/components/whatsapp/bots/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import WhatsappBotIcon from "@material-ui/icons/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,
|
||||
};
|
||||
24
metamigo-frontend/components/whatsapp/bots/shared.tsx
Normal file
24
metamigo-frontend/components/whatsapp/bots/shared.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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,23 @@
|
|||
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,29 @@
|
|||
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;
|
||||
10
metamigo-frontend/components/whatsapp/messages/index.ts
Normal file
10
metamigo-frontend/components/whatsapp/messages/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import WhatsappMessageIcon from "@material-ui/icons/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,
|
||||
};
|
||||
82
metamigo-frontend/i18n/en.ts
Normal file
82
metamigo-frontend/i18n/en.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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
metamigo-frontend/lib/absolute-url.ts
Normal file
35
metamigo-frontend/lib/absolute-url.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { IncomingMessage } from "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
metamigo-frontend/lib/apollo-client.ts
Normal file
40
metamigo-frontend/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: "/graphql" })]),
|
||||
cache: new InMemoryCache(),
|
||||
/*
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
fetchPolicy: "no-cache",
|
||||
errorPolicy: "ignore",
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: "no-cache",
|
||||
errorPolicy: "all",
|
||||
},
|
||||
},*/
|
||||
});
|
||||
210
metamigo-frontend/lib/cloudflare.ts
Normal file
210
metamigo-frontend/lib/cloudflare.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { promisify } from "util";
|
||||
import jwt from "jsonwebtoken";
|
||||
import jwksClient from "jwks-rsa";
|
||||
import * as Boom from "@hapi/boom";
|
||||
import * as Wreck from "@hapi/wreck";
|
||||
import Providers from "next-auth/providers";
|
||||
import type { Adapter } from "next-auth/adapters";
|
||||
import type { IncomingMessage } from "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, callback) => {
|
||||
client.getSigningKey(header.kid, function (err, key) {
|
||||
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 Providers.Credentials({
|
||||
id: cloudflareAccountProvider,
|
||||
name: "Cloudflare Access",
|
||||
credentials: {},
|
||||
authorize: cloudflareAuthorizeCallback(req, domain, verifier, adapter),
|
||||
});
|
||||
};
|
||||
17
metamigo-frontend/lib/dataprovider.ts
Normal file
17
metamigo-frontend/lib/dataprovider.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import pgDataProvider from "ra-postgraphile";
|
||||
import schema from "./graphql-schema.json";
|
||||
|
||||
export const metamigoDataProvider = async (client) => {
|
||||
const graphqlDataProvider = await pgDataProvider(
|
||||
client,
|
||||
// @ts-expect-error: Missing property
|
||||
{},
|
||||
{ introspection: { schema: schema.data.__schema } }
|
||||
);
|
||||
|
||||
const dataProvider = async (type, resource, params) => {
|
||||
return graphqlDataProvider(type, resource, params);
|
||||
};
|
||||
|
||||
return dataProvider;
|
||||
};
|
||||
232
metamigo-frontend/lib/nextauth-adapter.ts
Normal file
232
metamigo-frontend/lib/nextauth-adapter.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/* eslint-disable unicorn/no-null */
|
||||
import type { Adapter } from "next-auth/adapters";
|
||||
// @ts-expect-error: Missing export
|
||||
import type { AppOptions } from "next-auth";
|
||||
import * as Wreck from "@hapi/wreck";
|
||||
import * as Boom from "@hapi/boom";
|
||||
|
||||
import type { IAppConfig } from "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) {
|
||||
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) {
|
||||
super(message);
|
||||
this.name = "CreateUserError";
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
const basicHeader = (secret) =>
|
||||
"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",
|
||||
});
|
||||
|
||||
async function getAdapter(_appOptions: AppOptions) {
|
||||
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 getUserByProviderAccountId(
|
||||
providerId: string,
|
||||
providerAccountId: string
|
||||
) {
|
||||
try {
|
||||
const { payload } = await wreck.get(
|
||||
`getUserByProviderAccountId/${providerId}/${providerAccountId}`
|
||||
);
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (Boom.isBoom(error, 404)) return null;
|
||||
throw new Error("GET_USER_BY_PROVIDER_ACCOUNT_ID");
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
providerType: string,
|
||||
providerAccountId: string,
|
||||
refreshToken: string,
|
||||
accessToken: string,
|
||||
accessTokenExpires: number
|
||||
) {
|
||||
try {
|
||||
const payload = {
|
||||
userId,
|
||||
providerId,
|
||||
providerType,
|
||||
providerAccountId: `${providerAccountId}`, // must be a string
|
||||
refreshToken,
|
||||
accessToken,
|
||||
accessTokenExpires,
|
||||
};
|
||||
await wreck.put("linkAccount", {
|
||||
payload,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error("LINK_ACCOUNT_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(user: User) {
|
||||
try {
|
||||
const { payload } = await wreck.post("createSession", {
|
||||
payload: user,
|
||||
});
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
throw new Error("CREATE_SESSION_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
async function getSession(sessionToken: string) {
|
||||
try {
|
||||
const { payload } = await wreck.get(`getSession/${sessionToken}`);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (Boom.isBoom(error, 404)) return null;
|
||||
throw new Error("GET_SESSION_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 Promise.resolve({
|
||||
createUser,
|
||||
getUser,
|
||||
getUserByEmail,
|
||||
getUserByProviderAccountId,
|
||||
updateUser,
|
||||
// deleteUser,
|
||||
linkAccount,
|
||||
// unlinkAccount,
|
||||
createSession,
|
||||
getSession,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
// @ts-expect-error: Type error
|
||||
} as AdapterInstance<Profile, User, Session, unknown>);
|
||||
}
|
||||
|
||||
return {
|
||||
// @ts-expect-error: non-existent property
|
||||
getAdapter,
|
||||
};
|
||||
};
|
||||
31
metamigo-frontend/lib/phone-numbers.ts
Normal file
31
metamigo-frontend/lib/phone-numbers.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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) => {
|
||||
return E164Regex.test(phoneNumber);
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a phone number approximation, will clean out whitespace and punctuation.
|
||||
*/
|
||||
export const sanitizeE164Number = (phoneNumber) => {
|
||||
if (!phoneNumber) return "";
|
||||
if (!phoneNumber.trim()) return "";
|
||||
const sanitized = phoneNumber
|
||||
.replace(/\s/g, "")
|
||||
.replace(/\./g, "")
|
||||
.replace(/-/g, "")
|
||||
.replace(/\(/g, "")
|
||||
.replace(/\)/g, "");
|
||||
|
||||
if (sanitized[0] !== "+") return `+${sanitized}`;
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export const validateE164Number = regex(
|
||||
E164Regex,
|
||||
"Must start with a + and have no punctunation and no spaces."
|
||||
);
|
||||
5
metamigo-frontend/next-env.d.ts
vendored
Normal file
5
metamigo-frontend/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
23
metamigo-frontend/next.config.js
Normal file
23
metamigo-frontend/next.config.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
module.exports = {
|
||||
async redirects() {
|
||||
return [{ source: "/", destination: "/admin", permanent: true }];
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
/*
|
||||
{
|
||||
source: "/api/v1/:path*",
|
||||
destination: "http://localhost:3001/api/:path*",
|
||||
},
|
||||
*/
|
||||
{
|
||||
source: "/api/v1/:path*",
|
||||
destination: "/api/proxy/:path*",
|
||||
},
|
||||
{
|
||||
source: "/graphql",
|
||||
destination: "/api/graphql",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
51
metamigo-frontend/package.json
Normal file
51
metamigo-frontend/package.json
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.4",
|
||||
"@hapi/boom": "^10.0.0",
|
||||
"@hapi/wreck": "^18.0.0",
|
||||
"@mui/icons-material": "^5.11.0",
|
||||
"@mui/material": "^5.11.4",
|
||||
"@mui/styles": "^5.11.2",
|
||||
"@twilio/voice-sdk": "^2.2.0",
|
||||
"http-proxy-middleware": "^2.0.6",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwks-rsa": "^3.0.1",
|
||||
"material-ui-audio-player": "^1.7.1",
|
||||
"next": "13.1.2",
|
||||
"next-auth": "4.18.8",
|
||||
"ra-data-graphql": "^4.6.0",
|
||||
"ra-i18n-polyglot": "^4.7.0",
|
||||
"ra-input-rich-text": "^4.7.1",
|
||||
"ra-language-english": "^4.7.0",
|
||||
"ra-postgraphile": "^6.1.0",
|
||||
"react": "^18",
|
||||
"react-admin": "^4.7.1",
|
||||
"react-digit-input": "^2.1.0",
|
||||
"react-dom": "^18",
|
||||
"react-mic": "^12.4.6",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-timer-hook": "^3.0.5",
|
||||
"swr": "^2.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev -p 2999",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"fix:lint": "eslint --ext .js,.jsx,.ts,.tsx,.graphql --fix",
|
||||
"fix:prettier": "prettier --ignore-path .eslintignore \"**/*.{js,jsx,ts,tsx,graphql,md}\" --write",
|
||||
"test": "echo no tests",
|
||||
"lint": "next lint",
|
||||
"lint:lint": "eslint --ext .js,.jsx,.ts,.tsx,.graphql",
|
||||
"lint:prettier": "prettier --ignore-path .eslintignore \"**/*.{js,jsx,ts,tsx,graphql,md}\" --list-different"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "^13.1.2",
|
||||
"@types/hapi__wreck": "17.0.1",
|
||||
"@types/react": "^18",
|
||||
"@types/react-mic": "12.4.3",
|
||||
"typescript": "^4.9.4"
|
||||
}
|
||||
}
|
||||
13
metamigo-frontend/pages/_app.tsx
Normal file
13
metamigo-frontend/pages/_app.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import "../styles/globals.css";
|
||||
import { AppProps } from "next/app";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
function MetamigoStarter({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<SessionProvider session={pageProps.session}>
|
||||
<Component {...pageProps} />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetamigoStarter;
|
||||
15
metamigo-frontend/pages/admin.tsx
Normal file
15
metamigo-frontend/pages/admin.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { ApolloProvider } from "@apollo/client";
|
||||
import { apolloClient } from "../lib/apollo-client";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const MetamigoAdmin = dynamic(() => import("../components/MetamigoAdmin"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<MetamigoAdmin />
|
||||
</ApolloProvider>
|
||||
);
|
||||
}
|
||||
106
metamigo-frontend/pages/api/auth/[...nextauth].ts
Normal file
106
metamigo-frontend/pages/api/auth/[...nextauth].ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import NextAuth from "next-auth";
|
||||
import Google from "next-auth/providers/google";
|
||||
import GitHub from "next-auth/providers/github";
|
||||
import GitLab from "next-auth/providers/gitlab";
|
||||
import Cognito from "next-auth/providers/cognito";
|
||||
import { loadConfig, IAppConfig } from "config";
|
||||
import { MetamigoAdapter } from "../../../lib/nextauth-adapter";
|
||||
import { CloudflareAccessProvider } from "../../../lib/cloudflare";
|
||||
|
||||
const nextAuthOptions = (config: IAppConfig, req: NextApiRequest) => {
|
||||
const { nextAuth, cfaccess } = config;
|
||||
const adapter = MetamigoAdapter(config);
|
||||
const providers = [];
|
||||
|
||||
const { audience, domain } = cfaccess;
|
||||
const cloudflareAccessEnabled = audience && domain;
|
||||
if (cloudflareAccessEnabled)
|
||||
providers.push(CloudflareAccessProvider(audience, domain, adapter, req));
|
||||
else {
|
||||
if (nextAuth.google?.id)
|
||||
providers.push(
|
||||
Google({
|
||||
clientId: nextAuth.google.id,
|
||||
clientSecret: nextAuth.google.secret,
|
||||
})
|
||||
);
|
||||
|
||||
if (nextAuth.github?.id)
|
||||
providers.push(
|
||||
GitHub({
|
||||
clientId: nextAuth.github.id,
|
||||
clientSecret: nextAuth.github.secret,
|
||||
})
|
||||
);
|
||||
|
||||
if (nextAuth.gitlab?.id)
|
||||
providers.push(
|
||||
GitLab({
|
||||
clientId: nextAuth.gitlab.id,
|
||||
clientSecret: nextAuth.gitlab.secret,
|
||||
})
|
||||
);
|
||||
|
||||
if (nextAuth.cognito?.id)
|
||||
providers.push(
|
||||
Cognito({
|
||||
clientId: nextAuth.cognito.id,
|
||||
clientSecret: nextAuth.cognito.secret,
|
||||
// domain: nextAuth.cognito.domain,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (providers.length === 0)
|
||||
throw new Error(
|
||||
"No next-auth providers configured. See Metamigo configuration docs."
|
||||
);
|
||||
|
||||
return {
|
||||
secret: nextAuth.secret,
|
||||
session: {
|
||||
jwt: true,
|
||||
maxAge: 8 * 60 * 60, // 8 hours
|
||||
},
|
||||
jwt: {
|
||||
secret: nextAuth.secret,
|
||||
encryption: false,
|
||||
signingKey: nextAuth.signingKey,
|
||||
encryptionKey: nextAuth.encryptionKey,
|
||||
},
|
||||
providers,
|
||||
adapter,
|
||||
callbacks: {
|
||||
session: async (session: any, token: any) => {
|
||||
// make the user id available in the react client
|
||||
session.user.id = token.userId;
|
||||
return session;
|
||||
},
|
||||
jwt: async (token: any, user: any) => {
|
||||
const isSignIn = Boolean(user);
|
||||
// Add auth_time to token on signin in
|
||||
if (isSignIn) {
|
||||
// not sure what this does
|
||||
// if (!token.aud) token.aud;
|
||||
|
||||
token.aud = nextAuth.audience;
|
||||
token.picture = user.avatar;
|
||||
token.userId = user.id;
|
||||
token.role = user.userRole ? `app_${user.userRole}` : "app_anonymous";
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const nextAuth = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
): Promise<void> =>
|
||||
// @ts-expect-error: Type mismatch
|
||||
NextAuth(req, res, nextAuthOptions(await loadConfig(), req));
|
||||
|
||||
export default nextAuth;
|
||||
38
metamigo-frontend/pages/api/graphql/[[...path]].ts
Normal file
38
metamigo-frontend/pages/api/graphql/[[...path]].ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
|
||||
export default createProxyMiddleware({
|
||||
target:
|
||||
process.env.NODE_ENV === "production"
|
||||
? "http://metamigo-api:3001"
|
||||
: "http://localhost:3001",
|
||||
changeOrigin: true,
|
||||
pathRewrite: { "^/graphql": "/graphql" },
|
||||
xfwd: true,
|
||||
onProxyReq: function (proxyReq, req, _res) {
|
||||
const auth = proxyReq.getHeader("authorization");
|
||||
if (auth) {
|
||||
// pass along user provided authorization header
|
||||
return;
|
||||
}
|
||||
|
||||
// Else extract the session token from the cookie and pass
|
||||
// as bearer token to the proxy target
|
||||
let token = req.cookies["__Secure-next-auth.session-token"];
|
||||
if (!token) token = req.cookies["next-auth.session-token"];
|
||||
|
||||
//console.log(req.body);
|
||||
//if (req.body.query) console.log(req.body.query);
|
||||
if (token) {
|
||||
proxyReq.setHeader("authorization", `Bearer ${token}`);
|
||||
proxyReq.removeHeader("cookie");
|
||||
} else {
|
||||
console.error("no token found. proxied request to backend will fail.");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
38
metamigo-frontend/pages/api/proxy/[[...path]].js
Normal file
38
metamigo-frontend/pages/api/proxy/[[...path]].js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
|
||||
export default createProxyMiddleware({
|
||||
target:
|
||||
process.env.NODE_ENV === "production"
|
||||
? "http://metamigo-api:3001"
|
||||
: "http://localhost:3001",
|
||||
changeOrigin: true,
|
||||
pathRewrite: { "^/api/v1": "/api" },
|
||||
xfwd: true,
|
||||
onProxyReq: function (proxyReq, req, res) {
|
||||
const auth = proxyReq.getHeader("authorization");
|
||||
if (auth) {
|
||||
// pass along user provided authorization header
|
||||
return;
|
||||
}
|
||||
|
||||
// Else extract the session token from the cookie and pass
|
||||
// as bearer token to the proxy target
|
||||
//const token = req.cookies["next-auth.session-token"];
|
||||
let token = req.cookies["__Secure-next-auth.session-token"];
|
||||
if (!token) token = req.cookies["next-auth.session-token"];
|
||||
|
||||
if (token) {
|
||||
proxyReq.setHeader("authorization", `Bearer ${token}`);
|
||||
proxyReq.removeHeader("cookie");
|
||||
} else {
|
||||
console.error("no token found. proxied request to backend will fail.");
|
||||
}
|
||||
return;
|
||||
},
|
||||
});
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
29
metamigo-frontend/pages/index.tsx
Normal file
29
metamigo-frontend/pages/index.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { NextPage } from "next";
|
||||
import { Typography, Box, Button, Grid, Link } from "@material-ui/core";
|
||||
import { FC, PropsWithChildren, useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export const RedirectToAdmin: FC<PropsWithChildren> = ({ children }) => {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.push("/admin");
|
||||
});
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const Home: NextPage = () => (
|
||||
<Box>
|
||||
<Typography variant="h3">Metamigo</Typography>
|
||||
<Grid container justify="space-around" style={{ padding: 60 }}>
|
||||
<Grid item>
|
||||
<Link href="/admin">
|
||||
<Button variant="contained">Admin</Button>
|
||||
<RedirectToAdmin />
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default Home;
|
||||
27
metamigo-frontend/pages/login.tsx
Normal file
27
metamigo-frontend/pages/login.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Button } from "@material-ui/core";
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
|
||||
export default function myComponent() {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!session && (
|
||||
<>
|
||||
Not signed in <br />
|
||||
<Button variant="contained" onClick={signIn as any}>
|
||||
Sign in
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{session && (
|
||||
<>
|
||||
Signed in as {session.user?.email} <br />
|
||||
<Button variant="contained" onClick={signOut as any}>
|
||||
Sign out
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
metamigo-frontend/public/silence.mp3
Normal file
BIN
metamigo-frontend/public/silence.mp3
Normal file
Binary file not shown.
123
metamigo-frontend/styles/Home.module.css
Normal file
123
metamigo-frontend/styles/Home.module.css
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
.container {
|
||||
min-height: 100vh;
|
||||
padding: 0 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 5rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
border-top: 1px solid #eaeaea;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer img {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: #0070f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title a:hover,
|
||||
.title a:focus,
|
||||
.title a:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
line-height: 1.5;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.code {
|
||||
background: #fafafa;
|
||||
border-radius: 5px;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
max-width: 800px;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 1rem;
|
||||
flex-basis: 45%;
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 10px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus,
|
||||
.card:active {
|
||||
color: #0070f3;
|
||||
border-color: #0070f3;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
16
metamigo-frontend/styles/globals.css
Normal file
16
metamigo-frontend/styles/globals.css
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
20
metamigo-frontend/tsconfig.json
Normal file
20
metamigo-frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue