Add project management

This commit is contained in:
David Stotijn
2020-10-11 17:09:39 +02:00
parent ca707d17ea
commit fedb425381
22 changed files with 2080 additions and 322 deletions

View File

@ -21,12 +21,15 @@ import MenuIcon from "@material-ui/icons/Menu";
import HomeIcon from "@material-ui/icons/Home";
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
import SendIcon from "@material-ui/icons/Send";
import FolderIcon from "@material-ui/icons/Folder";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import ChevronRightIcon from "@material-ui/icons/ChevronRight";
import clsx from "clsx";
export enum Page {
Home,
GetStarted,
Projects,
ProxySetup,
ProxyLogs,
Sender,
@ -233,6 +236,22 @@ export function Layout({ title, page, children }: Props): JSX.Element {
<ListItemText primary="Sender" />
</ListItem>
</Link>
<Link href="/projects" passHref>
<ListItem
button
component="a"
key="projects"
selected={page === Page.Projects}
className={classes.listItem}
>
<Tooltip title="Projects">
<ListItemIcon className={classes.listItemIcon}>
<FolderIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Projects" />
</ListItem>
</Link>
</List>
</Drawer>
<main className={classes.content}>

View File

@ -0,0 +1,122 @@
import { gql, useMutation } from "@apollo/client";
import {
Box,
Button,
CircularProgress,
createStyles,
makeStyles,
TextField,
Theme,
Typography,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
projectName: {
marginTop: -6,
marginRight: theme.spacing(2),
},
button: {
marginRight: theme.spacing(2),
},
})
);
const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) {
openProject(name: $name) {
name
isActive
}
}
`;
function NewProject(): JSX.Element {
const classes = useStyles();
const [input, setInput] = useState(null);
const [openProject, { error, loading }] = useMutation(OPEN_PROJECT, {
onError: () => {},
onCompleted() {
input.value = "";
},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
name
isActive
type
}
`,
});
return DELETE;
},
},
});
},
});
const handleNewProjectForm = (e: React.SyntheticEvent) => {
e.preventDefault();
openProject({ variables: { name: input.value } });
};
return (
<div>
<Box mb={3}>
<Typography variant="h6">New project</Typography>
</Box>
<form onSubmit={handleNewProjectForm} autoComplete="off">
<TextField
className={classes.projectName}
color="secondary"
inputProps={{
id: "projectName",
ref: (node) => {
setInput(node);
},
}}
label="Project name"
placeholder="Project name…"
error={Boolean(error)}
helperText={error && error.message}
/>
<Button
className={classes.button}
type="submit"
variant="contained"
color="secondary"
size="large"
disabled={loading}
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
>
Create & open project
</Button>
</form>
</div>
);
}
export default NewProject;

View File

@ -0,0 +1,311 @@
import { gql, useMutation, useQuery } from "@apollo/client";
import {
Avatar,
Box,
Button,
CircularProgress,
createStyles,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
makeStyles,
Snackbar,
Theme,
Tooltip,
Typography,
} from "@material-ui/core";
import CloseIcon from "@material-ui/icons/Close";
import DescriptionIcon from "@material-ui/icons/Description";
import DeleteIcon from "@material-ui/icons/Delete";
import LaunchIcon from "@material-ui/icons/Launch";
import { Alert } from "@material-ui/lab";
import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
projectsList: {
backgroundColor: theme.palette.background.paper,
},
activeProject: {
color: theme.palette.getContrastText(theme.palette.secondary.main),
backgroundColor: theme.palette.secondary.main,
},
deleteProjectButton: {
color: theme.palette.error.main,
},
})
);
const PROJECTS = gql`
query Projects {
projects {
name
isActive
}
}
`;
const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) {
openProject(name: $name) {
name
isActive
}
}
`;
const CLOSE_PROJECT = gql`
mutation CloseProject {
closeProject {
success
}
}
`;
const DELETE_PROJECT = gql`
mutation DeleteProject($name: String!) {
deleteProject(name: $name) {
success
}
}
`;
function ProjectList(): JSX.Element {
const classes = useStyles();
const { loading: projLoading, error: projErr, data: projData } = useQuery(
PROJECTS
);
const [
openProject,
{ error: openProjErr, loading: openProjLoading },
] = useMutation(OPEN_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
name
isActive
type
}
`,
});
return DELETE;
},
},
});
},
});
const [closeProject, { error: closeProjErr }] = useMutation(CLOSE_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache) {
cache.modify({
fields: {
activeProject() {
return null;
},
projects(_, { DELETE }) {
return DELETE;
},
},
});
},
});
const [
deleteProject,
{ loading: deleteProjLoading, error: deleteProjErr },
] = useMutation(DELETE_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache) {
cache.modify({
fields: {
projects(_, { DELETE }) {
return DELETE;
},
},
});
setDeleteDiagOpen(false);
setDeleteNotifOpen(true);
},
});
const [deleteProjName, setDeleteProjName] = useState(null);
const [deleteDiagOpen, setDeleteDiagOpen] = useState(false);
const handleDeleteButtonClick = (name: string) => {
setDeleteProjName(name);
setDeleteDiagOpen(true);
};
const handleDeleteConfirm = () => {
deleteProject({ variables: { name: deleteProjName } });
};
const handleDeleteCancel = () => {
setDeleteDiagOpen(false);
};
const [deleteNotifOpen, setDeleteNotifOpen] = useState(false);
const handleCloseDeleteNotif = (_, reason?: string) => {
if (reason === "clickaway") {
return;
}
setDeleteNotifOpen(false);
};
return (
<div>
<Dialog open={deleteDiagOpen} onClose={handleDeleteCancel}>
<DialogTitle>
Delete project <strong>{deleteProjName}</strong>?
</DialogTitle>
<DialogContent>
<DialogContentText>
Deleting a project permanently removes its database file from disk.
This action is irreversible.
</DialogContentText>
{deleteProjErr && (
<Alert severity="error">
Error closing project: {deleteProjErr.message}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} autoFocus>
Cancel
</Button>
<Button
className={classes.deleteProjectButton}
onClick={handleDeleteConfirm}
disabled={deleteProjLoading}
>
Delete
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={deleteNotifOpen}
autoHideDuration={3000}
onClose={handleCloseDeleteNotif}
>
<Alert onClose={handleCloseDeleteNotif} severity="info">
Project <strong>{deleteProjName}</strong> was deleted.
</Alert>
</Snackbar>
<Box mb={3}>
<Typography variant="h6">Manage projects</Typography>
</Box>
<Box mb={4}>
{projLoading && <CircularProgress />}
{projErr && (
<Alert severity="error">
Error fetching projects: {projErr.message}
</Alert>
)}
{openProjErr && (
<Alert severity="error">
Error opening project: {openProjErr.message}
</Alert>
)}
{closeProjErr && (
<Alert severity="error">
Error closing project: {closeProjErr.message}
</Alert>
)}
</Box>
{projData?.projects.length > 0 && (
<List className={classes.projectsList}>
{projData.projects.map((project) => (
<ListItem key={project.name}>
<ListItemAvatar>
<Avatar
className={
project.isActive ? classes.activeProject : undefined
}
>
<DescriptionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText>
{project.name} {project.isActive && <em>(Active)</em>}
</ListItemText>
<ListItemSecondaryAction>
{project.isActive && (
<Tooltip title="Close project">
<IconButton onClick={() => closeProject()}>
<CloseIcon />
</IconButton>
</Tooltip>
)}
{!project.isActive && (
<Tooltip title="Open project">
<span>
<IconButton
disabled={openProjLoading || projLoading}
onClick={() =>
openProject({
variables: { name: project.name },
})
}
>
<LaunchIcon />
</IconButton>
</span>
</Tooltip>
)}
<Tooltip title="Delete project">
<span>
<IconButton
onClick={() => handleDeleteButtonClick(project.name)}
disabled={project.isActive}
>
<DeleteIcon />
</IconButton>
</span>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{projData?.projects.length === 0 && (
<Alert severity="info">
There are no projects. Create one to get started.
</Alert>
)}
</div>
);
}
export default ProjectList;

View File

@ -1,7 +1,12 @@
import { useRouter } from "next/router";
import { gql, useQuery } from "@apollo/client";
import { useState } from "react";
import { Box, Typography, CircularProgress } from "@material-ui/core";
import Link from "next/link";
import {
Box,
Typography,
CircularProgress,
Link as MaterialLink,
} from "@material-ui/core";
import Alert from "@material-ui/lab/Alert";
import RequestList from "./RequestList";
@ -42,6 +47,17 @@ function LogsOverview(): JSX.Element {
return <CircularProgress />;
}
if (error) {
if (error.graphQLErrors[0]?.extensions?.code === "no_active_project") {
return (
<Alert severity="info">
There is no project active.{" "}
<Link href="/projects" passHref>
<MaterialLink color="secondary">Create or open</MaterialLink>
</Link>{" "}
one first.
</Alert>
);
}
return <Alert severity="error">Error fetching logs: {error.message}</Alert>;
}

View File

@ -1,6 +1,5 @@
import { useMemo } from "react";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { concatPagination } from "@apollo/client/utilities";
let apolloClient;
@ -12,10 +11,8 @@ function createApolloClient() {
}),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
allPosts: concatPagination(),
},
Project: {
keyFields: ["name"],
},
},
}),

View File

@ -11,6 +11,12 @@ const theme = createMuiTheme({
secondary: {
main: teal["A400"],
},
info: {
main: teal["A400"],
},
success: {
main: teal["A400"],
},
},
typography: {
h2: {

View File

@ -0,0 +1,36 @@
import { Box, Link as MaterialLink, Typography } from "@material-ui/core";
import Link from "next/link";
import React from "react";
import Layout, { Page } from "../../components/Layout";
function Index(): JSX.Element {
return (
<Layout page={Page.GetStarted} title="Get started">
<Box p={4}>
<Box mb={3}>
<Typography variant="h4">Get started</Typography>
</Box>
<Typography paragraph>
Youve loaded a (new) project. Whats next? You can now use the MITM
proxy and review HTTP requests and responses via the{" "}
<Link href="/proxy/logs" passHref>
<MaterialLink color="secondary">Proxy logs</MaterialLink>
</Link>
. Stuck? Ask for help on the{" "}
<MaterialLink
href="https://github.com/dstotijn/hetty/discussions"
color="secondary"
target="_blank"
>
Discussions forum
</MaterialLink>
.
</Typography>
</Box>
</Layout>
);
}
export default Index;

View File

@ -1,17 +1,30 @@
import {
Avatar,
Box,
Button,
CircularProgress,
createStyles,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemText,
makeStyles,
TextField,
Theme,
Typography,
} from "@material-ui/core";
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
import SendIcon from "@material-ui/icons/Send";
import AddIcon from "@material-ui/icons/Add";
import FolderIcon from "@material-ui/icons/Folder";
import DescriptionIcon from "@material-ui/icons/Description";
import PlayArrowIcon from "@material-ui/icons/PlayArrow";
import Link from "next/link";
import { useState } from "react";
import { gql, useMutation, useQuery } from "@apollo/client";
import { useRouter } from "next/router";
import Layout, { Page } from "../components/Layout";
import { Alert } from "@material-ui/lab";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
@ -24,14 +37,108 @@ const useStyles = makeStyles((theme: Theme) =>
lineHeight: 2,
marginBottom: theme.spacing(5),
},
projectName: {
marginTop: -6,
marginRight: theme.spacing(2),
},
button: {
marginRight: theme.spacing(2),
},
activeProject: {
color: theme.palette.getContrastText(theme.palette.secondary.main),
backgroundColor: theme.palette.secondary.main,
},
})
);
const ACTIVE_PROJECT = gql`
query ActiveProject {
activeProject {
name
}
}
`;
const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) {
openProject(name: $name) {
name
isActive
}
}
`;
function Index(): JSX.Element {
const classes = useStyles();
const router = useRouter();
const [input, setInput] = useState(null);
const { error: activeProjErr, data: activeProjData } = useQuery(
ACTIVE_PROJECT,
{
pollInterval: 1000,
}
);
const [
openProject,
{ error: openProjErr, data: openProjData, loading: openProjLoading },
] = useMutation(OPEN_PROJECT, {
onError: () => {},
onCompleted({ openProject }) {
if (openProject) {
router.push("/get-started");
}
},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
name
isActive
type
}
`,
});
return DELETE;
},
},
});
},
});
const handleForm = (e: React.SyntheticEvent) => {
e.preventDefault();
openProject({ variables: { name: input.value } });
};
if (activeProjErr) {
return (
<Layout page={Page.Home} title="">
<Alert severity="error">
Error fetching active project: {activeProjErr.message}
</Alert>
</Layout>
);
}
return (
<Layout page={Page.Home} title="">
<Box p={4}>
@ -42,38 +149,105 @@ function Index(): JSX.Element {
The simple HTTP toolkit for security research.
</Typography>
</Box>
<Typography className={classes.subtitle} paragraph>
What if security testing was intuitive, powerful, and good looking?
What if it was <strong>free</strong>, instead of $400 per year?{" "}
<span className={classes.titleHighlight}>Hetty</span> is listening on{" "}
<code>:8080</code>
</Typography>
<Box>
<Link href="/proxy" passHref>
{activeProjData?.activeProject?.name ? (
<div>
<Box mb={1}>
<Typography variant="h6">Active project:</Typography>
</Box>
<Box ml={-2} mb={2}>
<List>
<ListItem>
<ListItemAvatar>
<Avatar className={classes.activeProject}>
<DescriptionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={activeProjData.activeProject.name} />
</ListItem>
</List>
</Box>
<div>
<Link href="/get-started" passHref>
<Button
className={classes.button}
variant="outlined"
component="a"
color="secondary"
size="large"
startIcon={<PlayArrowIcon />}
>
Get started
</Button>
</Link>
<Link href="/projects" passHref>
<Button
className={classes.button}
variant="outlined"
component="a"
size="large"
startIcon={<FolderIcon />}
>
Manage projects
</Button>
</Link>
</div>
</div>
) : (
<form onSubmit={handleForm} autoComplete="off">
<TextField
className={classes.projectName}
color="secondary"
inputProps={{
id: "projectName",
ref: (node) => {
setInput(node);
},
}}
label="Project name"
placeholder="Project name…"
error={Boolean(openProjErr)}
helperText={openProjErr && openProjErr.message}
/>
<Button
className={classes.button}
type="submit"
variant="contained"
color="secondary"
component="a"
size="large"
startIcon={<SettingsEthernetIcon />}
disabled={
openProjLoading || Boolean(openProjData?.openProject?.name)
}
startIcon={
openProjLoading || openProjData?.openProject ? (
<CircularProgress size={22} />
) : (
<AddIcon />
)
}
>
Setup proxy
Create project
</Button>
</Link>
<Link href="/proxy" passHref>
<Button
className={classes.button}
variant="contained"
color="primary"
component="a"
size="large"
startIcon={<SendIcon />}
>
Send HTTP requests
</Button>
</Link>
</Box>
<Link href="/projects" passHref>
<Button
className={classes.button}
variant="outlined"
component="a"
size="large"
startIcon={<FolderIcon />}
>
Open project
</Button>
</Link>
</form>
)}
</Box>
</Layout>
);

View File

@ -0,0 +1,33 @@
import { Box, Divider, Grid, Typography } from "@material-ui/core";
import Layout, { Page } from "../../components/Layout";
import NewProject from "../../components/projects/NewProject";
import ProjectList from "../../components/projects/ProjectList";
function Index(): JSX.Element {
return (
<Layout page={Page.Projects} title="Projects">
<Box p={4}>
<Box mb={3}>
<Typography variant="h4">Projects</Typography>
</Box>
<Typography paragraph>
Projects contain settings and data generated/processed by Hetty. They
are stored as SQLite database files on disk.
</Typography>
<Box my={4}>
<Divider />
</Box>
<Box mb={8}>
<NewProject />
</Box>
<Grid container>
<Grid item xs={12} sm={8} md={6} lg={6}>
<ProjectList />
</Grid>
</Grid>
</Box>
</Layout>
);
}
export default Index;