feat: introduce login page

This commit is contained in:
Ali BARIN
2022-03-07 18:51:57 +01:00
parent bb36748764
commit f5f7a998ca
16 changed files with 235 additions and 36 deletions

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import type { ContainerProps } from '@mui/material/Container';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from '@mui/material/useMediaQuery';
import MuiAppBar from '@mui/material/AppBar'; import MuiAppBar from '@mui/material/AppBar';
@@ -10,6 +11,7 @@ import MenuIcon from '@mui/icons-material/Menu';
import MenuOpenIcon from '@mui/icons-material/MenuOpen'; import MenuOpenIcon from '@mui/icons-material/MenuOpen';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import Container from 'components/Container';
import HideOnScroll from 'components/HideOnScroll'; import HideOnScroll from 'components/HideOnScroll';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
@@ -17,16 +19,24 @@ type AppBarProps = {
drawerOpen: boolean; drawerOpen: boolean;
onDrawerOpen: () => void; onDrawerOpen: () => void;
onDrawerClose: () => void; onDrawerClose: () => void;
maxWidth?: ContainerProps["maxWidth"];
}; };
export default function AppBar({ drawerOpen, onDrawerOpen, onDrawerClose }: AppBarProps): React.ReactElement { export default function AppBar(props: AppBarProps): React.ReactElement {
const {
drawerOpen,
onDrawerOpen,
onDrawerClose,
maxWidth = false,
} = props;
const theme = useTheme(); const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'), { noSsr: true }); const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'), { noSsr: true });
return ( return (
<Box sx={{ flexGrow: 1 }}>
<HideOnScroll> <HideOnScroll>
<MuiAppBar> <MuiAppBar>
<Container maxWidth={maxWidth} disableGutters>
<Toolbar> <Toolbar>
<IconButton <IconButton
size="large" size="large"
@@ -57,8 +67,8 @@ export default function AppBar({ drawerOpen, onDrawerOpen, onDrawerClose }: AppB
<SettingsIcon /> <SettingsIcon />
</IconButton> </IconButton>
</Toolbar> </Toolbar>
</Container>
</MuiAppBar> </MuiAppBar>
</HideOnScroll> </HideOnScroll>
</Box>
); );
} }

View File

@@ -3,15 +3,16 @@ import { FormProvider, useForm, FieldValues, SubmitHandler, UseFormReturn } from
import type { UseFormProps } from 'react-hook-form'; import type { UseFormProps } from 'react-hook-form';
type FormProps = { type FormProps = {
children: React.ReactNode; children?: React.ReactNode;
defaultValues?: UseFormProps['defaultValues']; defaultValues?: UseFormProps['defaultValues'];
onSubmit?: SubmitHandler<FieldValues>; onSubmit?: SubmitHandler<FieldValues>;
render?: (props: UseFormReturn) => React.ReactNode;
} }
const noop = () => null; const noop = () => null;
export default function Form(props: FormProps): React.ReactElement { export default function Form(props: FormProps): React.ReactElement {
const { children, onSubmit = noop, defaultValues, ...formProps } = props; const { children, onSubmit = noop, defaultValues, render, ...formProps } = props;
const methods: UseFormReturn = useForm({ const methods: UseFormReturn = useForm({
defaultValues, defaultValues,
}); });
@@ -23,7 +24,7 @@ export default function Form(props: FormProps): React.ReactElement {
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} {...formProps}> <form onSubmit={methods.handleSubmit(onSubmit)} {...formProps}>
{children} {render ? render(methods) : children}
</form> </form>
</FormProvider> </FormProvider>
); );

View File

@@ -1,5 +1,4 @@
import * as React from 'react'; import * as React from 'react';
import { useFormContext } from 'react-hook-form';
import type { IField } from '@automatisch/types'; import type { IField } from '@automatisch/types';
import PowerInput from 'components/PowerInput'; import PowerInput from 'components/PowerInput';
@@ -20,8 +19,6 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
namePrefix, namePrefix,
} = props; } = props;
const { control } = useFormContext();
const { const {
key: name, key: name,
label, label,
@@ -40,7 +37,6 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
<PowerInput <PowerInput
label={label} label={label}
description={description} description={description}
control={control}
name={computedName} name={computedName}
required={required} required={required}
// onBlur={onBlur} // onBlur={onBlur}
@@ -62,7 +58,6 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
label={label} label={label}
fullWidth fullWidth
helperText={description} helperText={description}
control={control}
clickToCopy={clickToCopy} clickToCopy={clickToCopy}
/> />
); );

View File

@@ -7,11 +7,11 @@ import AppBar from 'components/AppBar';
import Drawer from 'components/Drawer'; import Drawer from 'components/Drawer';
import Toolbar from '@mui/material/Toolbar'; import Toolbar from '@mui/material/Toolbar';
type LayoutProps = { type PublicLayoutProps = {
children: React.ReactNode; children: React.ReactNode;
} }
export default function Layout({ children }: LayoutProps): React.ReactElement { export default function PublicLayout({ children }: PublicLayoutProps): React.ReactElement {
const theme = useTheme(); const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'), { noSsr: true }); const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'), { noSsr: true });
const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens); const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens);
@@ -30,7 +30,7 @@ export default function Layout({ children }: LayoutProps): React.ReactElement {
onClose={closeDrawer} onClose={closeDrawer}
/> />
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1, }}>
<Toolbar /> <Toolbar />
{children} {children}

View File

@@ -0,0 +1,93 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@apollo/client';
import { UseFormReturn } from 'react-hook-form';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import LoadingButton from '@mui/lab/LoadingButton';
import * as URLS from 'config/urls';
import { setItem } from 'helpers/storage';
import { LOGIN } from 'graphql/mutations/login';
import Form from 'components/Form';
import TextField from 'components/TextField';
type FormValues = {
email: string;
password: string;
}
function renderFields(props: { loading: boolean }) {
const { loading = false } = props;
return (methods: UseFormReturn) => {
return (
<>
<TextField
label="Email"
name="email"
required
fullWidth
margin="dense"
/>
<TextField
label="Password"
name="password"
type="password"
required
fullWidth
margin="dense"
/>
<LoadingButton
type="submit"
variant="contained"
color="primary"
sx={{ boxShadow: 2, mt: 3 }}
loading={loading}
fullWidth
>
Login
</LoadingButton>
</>
);
}
}
function LoginForm() {
const navigate = useNavigate();
const [login, { loading }] = useMutation(LOGIN);
const handleSubmit = async (values: any) => {
const { data } = await login({
variables: {
input: values
},
});
const { token } = data.login;
setItem('token', token);
navigate(URLS.FLOWS);
};
const render = React.useMemo(() => renderFields({ loading }), [loading]);
return (
<Paper sx={{ px: 2, py: 4 }}>
<Typography
variant="h3"
align="center"
sx={{ borderBottom: '1px solid', borderColor: (theme) => theme.palette.text.disabled, pb: 2, mb: 2 }}
gutterBottom>
Login
</Typography>
<Form onSubmit={handleSubmit} render={render} />
</Paper>
);
};
export default LoginForm;

View File

@@ -2,10 +2,9 @@ import * as React from 'react';
import ClickAwayListener from '@mui/base/ClickAwayListener'; import ClickAwayListener from '@mui/base/ClickAwayListener';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import Popper from '@mui/material/Popper'; import Popper from '@mui/material/Popper';
import TextField from '@mui/material/TextField';
import InputLabel from '@mui/material/InputLabel'; import InputLabel from '@mui/material/InputLabel';
import FormHelperText from '@mui/material/FormHelperText'; import FormHelperText from '@mui/material/FormHelperText';
import { Controller, Control, FieldValues } from 'react-hook-form'; import { Controller, Control, FieldValues, useFormContext } from 'react-hook-form';
import { Editor, Transforms, Range, createEditor } from 'slate'; import { Editor, Transforms, Range, createEditor } from 'slate';
import { import {
Slate, Slate,
@@ -13,7 +12,6 @@ import {
useSelected, useSelected,
useFocused, useFocused,
} from 'slate-react'; } from 'slate-react';
import type { IExecutionStep, IStep } from '@automatisch/types';
import { import {
serialize, serialize,
@@ -29,7 +27,6 @@ import { VariableElement } from './types';
import { processStepWithExecutions } from './data'; import { processStepWithExecutions } from './data';
type PowerInputProps = { type PowerInputProps = {
control?: Control<FieldValues>;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onBlur?: (value: string) => void; onBlur?: (value: string) => void;
defaultValue?: string; defaultValue?: string;
@@ -44,8 +41,8 @@ type PowerInputProps = {
} }
const PowerInput = (props: PowerInputProps) => { const PowerInput = (props: PowerInputProps) => {
const { control } = useFormContext();
const { const {
control,
defaultValue = '', defaultValue = '',
onBlur, onBlur,
name, name,

View File

@@ -0,0 +1,41 @@
import * as React from 'react';
import Toolbar from '@mui/material/Toolbar';
import MuiAppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Container from 'components/Container';
import { FormattedMessage } from 'react-intl';
type LayoutProps = {
children: React.ReactNode;
}
export default function Layout({ children }: LayoutProps): React.ReactElement {
return (
<>
<MuiAppBar>
<Container maxWidth="lg" disableGutters>
<Toolbar>
<Typography
variant="h6"
noWrap
component="div"
sx={{ flexGrow: 1 }}
>
<FormattedMessage id="brandText" />
</Typography>
</Toolbar>
</Container>
</MuiAppBar>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Toolbar />
{children}
</Box>
</>
);
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { Controller, Control, FieldValues } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
import MuiTextField, { TextFieldProps as MuiTextFieldProps } from '@mui/material/TextField'; import MuiTextField, { TextFieldProps as MuiTextFieldProps } from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment'; import InputAdornment from '@mui/material/InputAdornment';
@@ -8,7 +8,6 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import copyInputValue from 'helpers/copyInputValue'; import copyInputValue from 'helpers/copyInputValue';
type TextFieldProps = { type TextFieldProps = {
control?: Control<FieldValues>;
shouldUnregister?: boolean; shouldUnregister?: boolean;
name: string; name: string;
clickToCopy?: boolean; clickToCopy?: boolean;
@@ -25,13 +24,13 @@ const createCopyAdornment = (ref: React.RefObject<HTMLInputElement | null>): Rea
<ContentCopyIcon color="primary" /> <ContentCopyIcon color="primary" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
); );
} }
export default function TextField(props: TextFieldProps): React.ReactElement { export default function TextField(props: TextFieldProps): React.ReactElement {
const { control } = useFormContext();
const inputRef = React.useRef<HTMLInputElement | null>(null); const inputRef = React.useRef<HTMLInputElement | null>(null);
const { const {
control,
required, required,
name, name,
defaultValue, defaultValue,

View File

@@ -2,6 +2,8 @@ export const DASHBOARD = '/dashboard';
export const CONNECTIONS = '/connections'; export const CONNECTIONS = '/connections';
export const EXPLORE = '/explore'; export const EXPLORE = '/explore';
export const LOGIN = '/login';
export const APPS = '/apps'; export const APPS = '/apps';
export const NEW_APP_CONNECTION = '/apps/new'; export const NEW_APP_CONNECTION = '/apps/new';
export const APP = (appKey: string): string => `/app/${appKey}`; export const APP = (appKey: string): string => `/app/${appKey}`;

View File

@@ -2,13 +2,22 @@ import { HttpLink, from } from '@apollo/client';
import type { ApolloLink } from '@apollo/client'; import type { ApolloLink } from '@apollo/client';
import { onError } from '@apollo/client/link/error'; import { onError } from '@apollo/client/link/error';
import * as URLS from 'config/urls';
import { getItem } from 'helpers/storage';
type CreateLinkOptions = { type CreateLinkOptions = {
uri: string; uri: string;
onError?: (message: string) => void; onError?: (message: string) => void;
}; };
const createHttpLink = (uri: CreateLinkOptions['uri']): ApolloLink => new HttpLink({ uri }); const createHttpLink = (uri: CreateLinkOptions['uri']): ApolloLink => {
const headers = {
authorization: getItem('token') || '',
};
return new HttpLink({ uri, headers });
}
const NOT_AUTHORISED = 'Not Authorised!';
const createErrorLink = (callback: CreateLinkOptions['onError']): ApolloLink => onError(({ graphQLErrors, networkError }) => { const createErrorLink = (callback: CreateLinkOptions['onError']): ApolloLink => onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) if (graphQLErrors)
graphQLErrors.forEach(({ message, locations, path }) => { graphQLErrors.forEach(({ message, locations, path }) => {
@@ -17,6 +26,10 @@ const createErrorLink = (callback: CreateLinkOptions['onError']): ApolloLink =>
console.log( console.log(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`, `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
); );
if (message === NOT_AUTHORISED) {
window.location.href = URLS.LOGIN;
}
}); });
if (networkError) { if (networkError) {

View File

@@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const LOGIN = gql`
mutation Login($input: LoginInput) {
login(input: $input) {
token
user {
id
email
}
}
}
`;

View File

@@ -0,0 +1,10 @@
const NAMESPACE = 'automatisch';
const makeKey = (key: string) => `${NAMESPACE}.${key}`;
export const setItem = (key: string, value: string) => {
return localStorage.setItem(makeKey(key), value);
};
export const getItem = (key: string) => {
return localStorage.getItem(makeKey(key));
}

View File

@@ -0,0 +1,7 @@
import { getItem } from 'helpers/storage';
export default function useAuthentication(): boolean {
const token = getItem('token');
return Boolean(token);
}

View File

@@ -0,0 +1,14 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Container from 'components/Container';
import LoginForm from 'components/LoginForm';
export default function Login(): React.ReactElement {
return (
<Box sx={{ display: 'flex', flex: 1, alignItems: 'center' }}>
<Container maxWidth="sm">
<LoginForm />
</Container>
</Box>
);
};

View File

@@ -1,10 +1,12 @@
import { Route, Routes, Navigate } from 'react-router-dom'; import { Route, Routes, Navigate } from 'react-router-dom';
import Layout from 'components/Layout'; import Layout from 'components/Layout';
import PublicLayout from 'components/PublicLayout';
import Applications from 'pages/Applications'; import Applications from 'pages/Applications';
import Application from 'pages/Application'; import Application from 'pages/Application';
import Flows from 'pages/Flows'; import Flows from 'pages/Flows';
import Flow from 'pages/Flow'; import Flow from 'pages/Flow';
import Explore from 'pages/Explore'; import Explore from 'pages/Explore';
import Login from 'pages/Login';
import EditorRoutes from 'pages/Editor/routes'; import EditorRoutes from 'pages/Editor/routes';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
@@ -22,6 +24,8 @@ export default (
<Route path={`${URLS.EDITOR}/*`} element={<EditorRoutes />} /> <Route path={`${URLS.EDITOR}/*`} element={<EditorRoutes />} />
<Route path={URLS.LOGIN} element={<PublicLayout><Login /></PublicLayout>} />
<Route path="/" element={<Navigate to={URLS.FLOWS} />} /> <Route path="/" element={<Navigate to={URLS.FLOWS} />} />
<Route element={<Layout><div>404</div></Layout>} /> <Route element={<Layout><div>404</div></Layout>} />