diff --git a/server/src/api/record.ts b/server/src/api/record.ts index bd5e2f74..9d8dfa29 100644 --- a/server/src/api/record.ts +++ b/server/src/api/record.ts @@ -106,7 +106,7 @@ router.get("/robots/:id", requireAPIKey, async (req: Request, res: Response) => } }); -// TODO: Format runs to send more data formatted +// TODO: Format runs to send more data formatted router.get("/robots/:id/runs", requireAPIKey, async (req: Request, res: Response) => { try { const runs = await Run.findAll({ diff --git a/server/src/browser-management/classes/RemoteBrowser.ts b/server/src/browser-management/classes/RemoteBrowser.ts index 70677ed4..11b9a124 100644 --- a/server/src/browser-management/classes/RemoteBrowser.ts +++ b/server/src/browser-management/classes/RemoteBrowser.ts @@ -275,7 +275,7 @@ export class RemoteBrowser { if (page) { await this.stopScreencast(); this.currentPage = page; - await this.currentPage.setViewportSize({ height: 720, width: 1280 }) + await this.currentPage.setViewportSize({ height: 500, width: 1280 }) this.client = await this.currentPage.context().newCDPSession(this.currentPage); this.socket.emit('urlChanged', this.currentPage.url()); await this.makeAndEmitScreenshot(); diff --git a/src/components/atoms/GenericModal.tsx b/src/components/atoms/GenericModal.tsx index bb6dc77f..56b3df43 100644 --- a/src/components/atoms/GenericModal.tsx +++ b/src/components/atoms/GenericModal.tsx @@ -33,13 +33,13 @@ const defaultModalStyle = { top: '50%', left: '50%', transform: 'translate(-50%, -50%)', - width: '50%', + width: 500, bgcolor: 'background.paper', boxShadow: 24, p: 4, height: '30%', display: 'block', - overflow: 'hidden', + overflow: 'scroll', padding: '5px 25px 10px 25px', zIndex: 3147483647, -}; +}; \ No newline at end of file diff --git a/src/components/molecules/BrowserNavBar.tsx b/src/components/molecules/BrowserNavBar.tsx index b35e4d7f..cf00c8bd 100644 --- a/src/components/molecules/BrowserNavBar.tsx +++ b/src/components/molecules/BrowserNavBar.tsx @@ -12,6 +12,7 @@ import { UrlForm } from './UrlForm'; import { useCallback, useEffect, useState } from "react"; import { useSocketStore } from "../../context/socket"; import { getCurrentUrl } from "../../api/recording"; +import { useGlobalInfoStore } from '../../context/globalInfo'; const StyledNavBar = styled.div<{ browserWidth: number }>` display: flex; @@ -31,8 +32,7 @@ const BrowserNavBar: FC = ({ }) => { const { socket } = useSocketStore(); - - const [currentUrl, setCurrentUrl] = useState('https://'); + const { recordingUrl, setRecordingUrl } = useGlobalInfoStore(); const handleRefresh = useCallback((): void => { socket?.emit('input:refresh'); @@ -44,14 +44,13 @@ const BrowserNavBar: FC = ({ const handleCurrentUrlChange = useCallback((url: string) => { handleUrlChanged(url); - setCurrentUrl(url); - }, [handleUrlChanged, currentUrl]); + setRecordingUrl(url); + }, [handleUrlChanged, recordingUrl]); useEffect(() => { getCurrentUrl().then((response) => { if (response) { handleUrlChanged(response); - setCurrentUrl(response); } }).catch((error) => { console.log("Fetching current url failed"); @@ -72,12 +71,13 @@ const BrowserNavBar: FC = ({ const addAddress = (address: string) => { if (socket) { handleUrlChanged(address); + setRecordingUrl(address); handleGoTo(address); } }; return ( - + { @@ -111,7 +111,7 @@ const BrowserNavBar: FC = ({ diff --git a/src/components/molecules/BrowserTabs.tsx b/src/components/molecules/BrowserTabs.tsx index 2cda3937..a430d26f 100644 --- a/src/components/molecules/BrowserTabs.tsx +++ b/src/components/molecules/BrowserTabs.tsx @@ -31,7 +31,7 @@ export const BrowserTabs = ( return ( = ({ isOpen, se ); -} +} \ No newline at end of file diff --git a/src/components/molecules/NavBar.tsx b/src/components/molecules/NavBar.tsx index 1bfec709..f06bebd3 100644 --- a/src/components/molecules/NavBar.tsx +++ b/src/components/molecules/NavBar.tsx @@ -6,10 +6,12 @@ import { useGlobalInfoStore } from "../../context/globalInfo"; import { Button, IconButton } from "@mui/material"; import { RecordingIcon } from "../atoms/RecorderIcon"; import { SaveRecording } from "./SaveRecording"; -import { Circle } from "@mui/icons-material"; +import { Circle, Add, Logout, Clear } from "@mui/icons-material"; import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { AuthContext } from '../../context/auth'; +import { GenericModal } from '../atoms/GenericModal'; +import TextField from '@mui/material/TextField'; interface NavBarProps { newRecording: () => void; @@ -17,11 +19,15 @@ interface NavBarProps { isRecording: boolean; } -export const NavBar = ({ newRecording, recordingName, isRecording }: NavBarProps) => { +export const NavBar: React.FC = ({ newRecording, recordingName, isRecording }) => { - const { notify, browserId, setBrowserId, recordingLength } = useGlobalInfoStore(); + const { notify, browserId, setBrowserId, recordingLength, recordingUrl, setRecordingUrl } = useGlobalInfoStore(); const { state, dispatch } = useContext(AuthContext); const { user } = state; + const [isModalOpen, setModalOpen] = useState(false); + + console.log(`Recording URL: ${recordingUrl}`) + const navigate = useNavigate(); const logout = async () => { @@ -48,9 +54,14 @@ export const NavBar = ({ newRecording, recordingName, isRecording }: NavBarProps setBrowserId(null); await stopRecording(browserId); } + setModalOpen(true); + }; + + const startRecording = () => { + setModalOpen(false); newRecording(); - notify('info', 'New Recording started'); - } + notify('info', 'New Recording started for ' + recordingUrl); + }; return ( @@ -68,61 +79,92 @@ export const NavBar = ({ newRecording, recordingName, isRecording }: NavBarProps display: 'flex', justifyContent: 'flex-end', }}> - + + Create Robot + + + + Logout + + ) : - {isRecording ? 'NEW' : 'RECORD'} - + '&:hover': { color: 'white', backgroundColor: 'red' } + }} onClick={goToMainMenu}> + + Discard + } { recordingLength > 0 ? : null } - {isRecording ? - : null} - + setModalOpen(false)}> +
+

Enter URL

+ setRecordingUrl(e.target.value)} + style={{ marginBottom: '20px' }} + /> + +
+
) : "" } @@ -133,13 +175,13 @@ export const NavBar = ({ newRecording, recordingName, isRecording }: NavBarProps const NavBarWrapper = styled.div` grid-area: navbar; - background-color: #3f4853; + background-color: white; padding:5px; display: flex; justify-content: space-between; `; const ProjectName = styled.b` - color: white; + color: #3f4853; font-size: 1.3em; `; diff --git a/src/components/molecules/RecordingsTable.tsx b/src/components/molecules/RecordingsTable.tsx index 3da2a452..904b6011 100644 --- a/src/components/molecules/RecordingsTable.tsx +++ b/src/components/molecules/RecordingsTable.tsx @@ -14,9 +14,15 @@ import { Schedule, DeleteForever, Edit, PlayCircle } from "@mui/icons-material"; import LinkIcon from '@mui/icons-material/Link'; import { useGlobalInfoStore } from "../../context/globalInfo"; import { deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; +import { Typography } from '@mui/material'; + +/** TODO: + * 1. allow editing existing robot after persisting browser steps + * 2. show robot settings: id, url, etc. +*/ interface Column { - id: 'id' | 'interpret' | 'name' | 'createdAt' | 'edit' | 'updatedAt' | 'delete' | 'schedule' | 'integrate'; + id: 'interpret' | 'name' | 'delete' | 'schedule' | 'integrate'; label: string; minWidth?: number; align?: 'right'; @@ -24,20 +30,19 @@ interface Column { } const columns: readonly Column[] = [ - { id: 'id', label: 'ID', minWidth: 80 }, { id: 'interpret', label: 'Run', minWidth: 80 }, { id: 'name', label: 'Name', minWidth: 80 }, - { - id: 'createdAt', - label: 'Created at', - minWidth: 80, - //format: (value: string) => value.toLocaleString('en-US'), - }, - { - id: 'edit', - label: 'Edit', - minWidth: 80, - }, + // { + // id: 'createdAt', + // label: 'Created at', + // minWidth: 80, + // //format: (value: string) => value.toLocaleString('en-US'), + // }, + // { + // id: 'edit', + // label: 'Edit', + // minWidth: 80, + // }, { id: 'schedule', label: 'Schedule', @@ -48,12 +53,12 @@ const columns: readonly Column[] = [ label: 'Integrate', minWidth: 80, }, - { - id: 'updatedAt', - label: 'Updated at', - minWidth: 80, - //format: (value: string) => value.toLocaleString('en-US'), - }, + // { + // id: 'updatedAt', + // label: 'Updated at', + // minWidth: 80, + // //format: (value: string) => value.toLocaleString('en-US'), + // }, { id: 'delete', label: 'Delete', @@ -121,7 +126,10 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl return ( - + + My Robots + + @@ -159,16 +167,16 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl handleRunRecording(row.id, row.name, row.params || [])} /> ); - case 'edit': - return ( - - { - handleEditRecording(row.id, row.name); - }} sx={{ '&:hover': { color: '#1976d2', backgroundColor: 'transparent' } }}> - - - - ); + // case 'edit': + // return ( + // + // { + // handleEditRecording(row.id, row.name); + // }} sx={{ '&:hover': { color: '#1976d2', backgroundColor: 'transparent' } }}> + // + // + // + // ); case 'schedule': return ( diff --git a/src/components/molecules/RunContent.tsx b/src/components/molecules/RunContent.tsx index 5b219f16..2ac4d369 100644 --- a/src/components/molecules/RunContent.tsx +++ b/src/components/molecules/RunContent.tsx @@ -1,4 +1,4 @@ -import { Box, Tabs, Typography, Tab } from "@mui/material"; +import { Box, Tabs, Typography, Tab, Paper } from "@mui/material"; import Highlight from "react-highlight"; import Button from "@mui/material/Button"; import * as React from "react"; @@ -8,8 +8,14 @@ import SettingsIcon from '@mui/icons-material/Settings'; import ImageIcon from '@mui/icons-material/Image'; import ArticleIcon from '@mui/icons-material/Article'; import { Buffer } from 'buffer'; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import AssignmentIcon from '@mui/icons-material/Assignment'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; interface RunContentProps { row: Data, @@ -21,19 +27,34 @@ interface RunContentProps { export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRef, abortRunHandler }: RunContentProps) => { const [tab, setTab] = React.useState('log'); + const [tableData, setTableData] = useState([]); + const [columns, setColumns] = useState([]); useEffect(() => { setTab(tab); }, [interpretationInProgress]) + useEffect(() => { + if (row.serializableOutput && Object.keys(row.serializableOutput).length > 0) { + const firstKey = Object.keys(row.serializableOutput)[0]; + const data = row.serializableOutput[firstKey]; + if (Array.isArray(data)) { + setTableData(data); + if (data.length > 0) { + setColumns(Object.keys(data[0])); + } + } + } + }, [row.serializableOutput]); + return ( setTab(newTab)} aria-label="run-content-tabs"> - + {/* */} - + @@ -107,29 +128,52 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
- Serializable output + Serializable output + {Object.keys(row.serializableOutput).map((key) => { return (
{key}: Download + download={key} style={{ margin: '10px' }}>Download as JSON - -
-                        {row.serializableOutput[key] ? JSON.stringify(row.serializableOutput[key], null, 2)
-                          : 'The output is empty.'}
-                      
-
) })} + {tableData.length > 0 ? ( + +
+ + + {columns.map((column) => ( + {column} + ))} + + + + {tableData.map((row, index) => ( + + {columns.map((column) => ( + {row[column]} + ))} + + ))} + +
+
+ ) : ( + +
+                    {JSON.stringify(row.serializableOutput, null, 2)}
+                  
+
+ )} } {row.binaryOutput diff --git a/src/components/molecules/RunsTable.tsx b/src/components/molecules/RunsTable.tsx index 7f414c58..01b940c2 100644 --- a/src/components/molecules/RunsTable.tsx +++ b/src/components/molecules/RunsTable.tsx @@ -16,7 +16,7 @@ import { Accordion, AccordionSummary, AccordionDetails, Typography } from '@mui/ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; interface Column { - id: 'status' | 'name' | 'startedAt' | 'finishedAt' | 'duration' | 'task' | 'runId' | 'delete'; + id: 'status' | 'name' | 'startedAt' | 'finishedAt' | 'runId' | 'delete'; label: string; minWidth?: number; align?: 'right'; @@ -25,12 +25,11 @@ interface Column { export const columns: readonly Column[] = [ { id: 'status', label: 'Status', minWidth: 80 }, - { id: 'name', label: 'Name', minWidth: 80 }, + { id: 'name', label: 'Robot Name', minWidth: 80 }, { id: 'startedAt', label: 'Started at', minWidth: 80 }, { id: 'finishedAt', label: 'Finished at', minWidth: 80 }, - { id: 'duration', label: 'Duration', minWidth: 80 }, - { id: 'runId', label: 'Run id', minWidth: 80 }, - { id: 'task', label: 'Task', minWidth: 80 }, + { id: 'runId', label: 'Run ID', minWidth: 80 }, + // { id: 'task', label: 'Task', minWidth: 80 }, { id: 'delete', label: 'Delete', minWidth: 80 }, ]; @@ -40,8 +39,7 @@ export interface Data { name: string; startedAt: string; finishedAt: string; - duration: string; - task: string; + // task: string; log: string; runId: string; interpreterSettings: RunSettings; @@ -78,7 +76,6 @@ export const RunsTable = ( if (runs) { const parsedRows: Data[] = []; runs.map((run: any, index) => { - // const run = JSON.parse(run); parsedRows.push({ id: index, ...run, diff --git a/src/components/molecules/SaveRecording.tsx b/src/components/molecules/SaveRecording.tsx index 05ff43af..c2f6911b 100644 --- a/src/components/molecules/SaveRecording.tsx +++ b/src/components/molecules/SaveRecording.tsx @@ -8,6 +8,7 @@ import { TextField, Typography } from "@mui/material"; import { WarningText } from "../atoms/texts"; import NotificationImportantIcon from "@mui/icons-material/NotificationImportant"; import FlagIcon from '@mui/icons-material/Flag'; +import { DoneAll } from '@mui/icons-material' import { useNavigate } from 'react-router-dom'; interface SaveRecordingProps { @@ -68,26 +69,32 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { return (
- + Finish + setOpenModal(false)} modalStyle={modalStyle}> -
- Save the recording as: + + Save the robot as @@ -97,7 +104,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { - Recording already exists, please confirm the recording's overwrite. + Robot with this name already exists, please confirm the Robot's overwrite. ) : diff --git a/src/components/molecules/UrlForm.tsx b/src/components/molecules/UrlForm.tsx index a6681ab9..b8b8658c 100644 --- a/src/components/molecules/UrlForm.tsx +++ b/src/components/molecules/UrlForm.tsx @@ -1,12 +1,12 @@ -import { useState, useCallback, useEffect, } from 'react'; -import type { SyntheticEvent, } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import type { SyntheticEvent } from 'react'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; - import { NavBarForm, NavBarInput } from "../atoms/form"; import { UrlFormButton } from "../atoms/buttons/buttons"; import { useSocketStore } from '../../context/socket'; import { Socket } from "socket.io-client"; +// TODO: Bring back REFRESHHHHHHH type Props = { currentAddress: string; handleRefresh: (socket: Socket) => void; @@ -18,45 +18,43 @@ export const UrlForm = ({ handleRefresh, setCurrentAddress, }: Props) => { - // states: const [address, setAddress] = useState(currentAddress); - // context: const { socket } = useSocketStore(); - - const areSameAddresses = address === currentAddress; + const lastSubmittedRef = useRef(''); const onChange = useCallback((event: SyntheticEvent): void => { setAddress((event.target as HTMLInputElement).value); - }, [address]); + }, []); + + const submitForm = useCallback((url: string): void => { + // Add protocol if missing + if (!/^(?:f|ht)tps?\:\/\//.test(url)) { + url = "https://" + url; + setAddress(url); // Update the input field to reflect protocol addition + } + + try { + // Validate the URL + new URL(url); + setCurrentAddress(url); + lastSubmittedRef.current = url; // Update the last submitted URL + } catch (e) { + alert(`ERROR: ${url} is not a valid url!`); + } + }, [setCurrentAddress]); const onSubmit = (event: SyntheticEvent): void => { event.preventDefault(); - let url = address; - - // add protocol if missing - if (!/^(?:f|ht)tps?\:\/\//.test(address)) { - url = "https://" + address; - setAddress(url); - } - - if (areSameAddresses) { - if (socket) { - handleRefresh(socket); - } - } else { - try { - // try the validity of url - new URL(url); - setCurrentAddress(url); - } catch (e) { - alert(`ERROR: ${url} is not a valid url!`); - } - } + submitForm(address); }; + // Sync internal state with currentAddress prop when it changes and auto-submit once useEffect(() => { - setAddress(currentAddress) - }, [currentAddress]); + setAddress(currentAddress); + if (currentAddress !== '' && currentAddress !== lastSubmittedRef.current) { + submitForm(currentAddress); + } + }, [currentAddress, submitForm]); return ( @@ -70,4 +68,4 @@ export const UrlForm = ({ ); -}; +}; \ No newline at end of file diff --git a/src/components/organisms/ApiKey.tsx b/src/components/organisms/ApiKey.tsx index 030194de..35dbd033 100644 --- a/src/components/organisms/ApiKey.tsx +++ b/src/components/organisms/ApiKey.tsx @@ -89,8 +89,9 @@ const ApiKeyManager = () => { return ( - Manage Your API Key - + + Manage Your API Key + {apiKey ? ( @@ -106,18 +107,18 @@ const ApiKeyManager = () => { {apiKeyName}{showKey ? `${apiKey?.substring(0, 10)}...` : '***************'} - + - + setShowKey(!showKey)}> - - + + @@ -129,7 +130,7 @@ const ApiKeyManager = () => { ) : ( <> You haven't generated an API key yet. - diff --git a/src/components/organisms/BrowserContent.tsx b/src/components/organisms/BrowserContent.tsx index 21c337f5..18fbce19 100644 --- a/src/components/organisms/BrowserContent.tsx +++ b/src/components/organisms/BrowserContent.tsx @@ -6,7 +6,9 @@ import { useBrowserDimensionsStore } from "../../context/browserDimensions"; import { BrowserTabs } from "../molecules/BrowserTabs"; import { useSocketStore } from "../../context/socket"; import { getCurrentTabs, getCurrentUrl, interpretCurrentRecording } from "../../api/recording"; +import { Box } from '@mui/material'; +// TODO: Tab !show currentUrl after recordingUrl global state export const BrowserContent = () => { const { width } = useBrowserDimensionsStore(); const { socket } = useSocketStore(); @@ -113,7 +115,7 @@ export const BrowserContent = () => { }, []) return ( - + <> { /> - + ); } const BrowserContentWrapper = styled.div` - grid-area: browser; `; \ No newline at end of file diff --git a/src/components/organisms/LeftSidePanel.tsx b/src/components/organisms/LeftSidePanel.tsx index 09c5fc46..7504be2e 100644 --- a/src/components/organisms/LeftSidePanel.tsx +++ b/src/components/organisms/LeftSidePanel.tsx @@ -63,7 +63,7 @@ export const LeftSidePanel = ( if (id) { fetchWorkflow(id, workflowHandler); } - }, (1000 * 60 * 15)); + }, (900 * 60 * 15)); return () => clearInterval(interval) }, [id]); diff --git a/src/components/organisms/MainMenu.tsx b/src/components/organisms/MainMenu.tsx index 6d8c6324..9be030e4 100644 --- a/src/components/organisms/MainMenu.tsx +++ b/src/components/organisms/MainMenu.tsx @@ -19,10 +19,11 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu + }} value="recordings" label="Robots" /> { return ( - - - Proxy Configuration - + + Proxy Configuration + + { fullWidth required error={!!errors.server_url} - helperText={errors.server_url || `Proxy to be used for all requests. - HTTP and SOCKS proxies are supported, for example http://myproxy.com:3128 or - socks5://myproxy.com:3128. Short form myproxy.com:3128 is considered an HTTP proxy.`} + helperText={errors.server_url || `Proxy to be used for all robots. HTTP and SOCKS proxies are supported. + Example http://myproxy.com:3128 or socks5://myproxy.com:3128. + Short form myproxy.com:3128 is considered an HTTP proxy.`} /> @@ -147,7 +146,7 @@ const ProxyForm: React.FC = () => { > Add Proxy - + ); }; diff --git a/src/context/globalInfo.tsx b/src/context/globalInfo.tsx index 7e7a3826..137ce14c 100644 --- a/src/context/globalInfo.tsx +++ b/src/context/globalInfo.tsx @@ -20,6 +20,8 @@ interface GlobalInfo { setRecordingId: (newId: string | null) => void; recordingName: string; setRecordingName: (recordingName: string) => void; + recordingUrl: string; + setRecordingUrl: (recordingUrl: string) => void; }; class GlobalInfoStore implements Partial { @@ -35,6 +37,7 @@ class GlobalInfoStore implements Partial { recordings: string[] = []; rerenderRuns = false; recordingName = ''; + recordingUrl = 'https://'; }; const globalInfoStore = new GlobalInfoStore(); @@ -51,6 +54,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { const [recordingLength, setRecordingLength] = useState(globalInfoStore.recordingLength); const [recordingId, setRecordingId] = useState(globalInfoStore.recordingId); const [recordingName, setRecordingName] = useState(globalInfoStore.recordingName); + const [recordingUrl, setRecordingUrl] = useState(globalInfoStore.recordingUrl); const notify = (severity: 'error' | 'warning' | 'info' | 'success', message: string) => { setNotification({ severity, message, isOpen: true }); @@ -86,7 +90,9 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { recordingId, setRecordingId, recordingName, - setRecordingName + setRecordingName, + recordingUrl, + setRecordingUrl, }} > {children} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 53f3d01e..f9eb8ab4 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -104,7 +104,6 @@ const Login = () => { )} - {/* Redirect to Register */} Don’t have an account?{' '} diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 34d6811d..7f1f0193 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -158,7 +158,7 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => { } return ( - + {DisplayContent()} diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 3f6f2c55..d3ccd666 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -50,64 +50,66 @@ const Register = () => { }; return ( - - - - Create an account + + + Create an account + + + + + + + Already have an account?{' '} + + Login + - - - - - - Already have an account?{' '} - - Login - - - - + ); };