diff --git a/src/components/CANFilter/Table/__snapshots__/index.test.jsx.snap b/src/components/CANFilter/Table/__snapshots__/index.test.jsx.snap
index b638290..0033f76 100644
--- a/src/components/CANFilter/Table/__snapshots__/index.test.jsx.snap
+++ b/src/components/CANFilter/Table/__snapshots__/index.test.jsx.snap
@@ -194,6 +194,23 @@ exports[`CANFiltersTable Render 1`] = `
/>
+
+
+
|
+
+
+
|
+
+
+
diff --git a/src/components/Cars/Status/__snapshots__/CANFiltersTab.test.jsx.snap b/src/components/Cars/Status/__snapshots__/CANFiltersTab.test.jsx.snap
index f2dd2d2..9262594 100644
--- a/src/components/Cars/Status/__snapshots__/CANFiltersTab.test.jsx.snap
+++ b/src/components/Cars/Status/__snapshots__/CANFiltersTab.test.jsx.snap
@@ -193,6 +193,23 @@ exports[`CANFiltersTab Render 1`] = `
/>
+
+
+
|
+
+
+
|
+
+
+
diff --git a/src/components/Certificates/Add/CreateForm.jsx b/src/components/Certificates/Add/CreateForm.jsx
new file mode 100644
index 0000000..4a67e96
--- /dev/null
+++ b/src/components/Certificates/Add/CreateForm.jsx
@@ -0,0 +1,90 @@
+import React, { useRef, useState } from "react";
+import {
+ Button,
+ FormControlLabel,
+ FormLabel,
+ Radio,
+ RadioGroup,
+ TextField,
+} from "@material-ui/core";
+
+import useStyles from "../../useStyles";
+import { CertTypes } from "../../Contexts/CertificateContext";
+
+const CreateForm = ({ onCreate, busy }) => {
+ const classes = useStyles();
+ const vinEl = useRef(null);
+ const [certType, setCertType] = useState(CertTypes.TREX);
+
+ const onSubmit = async (event) => {
+ event.preventDefault();
+
+ if (onCreate)
+ onCreate({
+ vin: vinEl.current.value,
+ type: certType,
+ });
+ };
+
+ const onCertTypeChange = (event) => {
+ setCertType(event.target.value);
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default CreateForm;
diff --git a/src/components/Certificates/Add/DownloadCerts.jsx b/src/components/Certificates/Add/DownloadCerts.jsx
new file mode 100644
index 0000000..4e206c9
--- /dev/null
+++ b/src/components/Certificates/Add/DownloadCerts.jsx
@@ -0,0 +1,50 @@
+import { Button } from "@material-ui/core";
+import React from "react";
+
+import DownloadFileLink from "../../Controls/DownloadFileLink";
+import useStyles from "../../useStyles";
+
+const CertMimeType = "application/x-pem-file";
+
+const DownloadCerts = ({ vin, publicCert, privateCert, onChangeView }) => {
+ const classes = useStyles();
+
+ const onNewCert = (event) => {
+ event.preventDefault();
+ if (!onChangeView) return;
+ onChangeView();
+ };
+ return (
+
+
Download Certifcates
+
+
+
+ );
+};
+
+export default DownloadCerts;
diff --git a/src/components/Certificates/Add/DownloadCerts.test.jsx b/src/components/Certificates/Add/DownloadCerts.test.jsx
new file mode 100644
index 0000000..0b4af34
--- /dev/null
+++ b/src/components/Certificates/Add/DownloadCerts.test.jsx
@@ -0,0 +1,25 @@
+import React from "react";
+import { render, waitFor } from "@testing-library/react";
+
+import DownloadCerts from "./DownloadCerts";
+
+describe("DownloadCerts", () => {
+ beforeAll(() => {
+ global.URL.createObjectURL = jest.fn();
+ global.URL.revokeObjectURL = jest.fn();
+ });
+
+ it("Render", async () => {
+ const { container } = render(
+
+ );
+ await waitFor(() => {
+ /* render */
+ });
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/components/Certificates/Add/__snapshots__/DownloadCerts.test.jsx.snap b/src/components/Certificates/Add/__snapshots__/DownloadCerts.test.jsx.snap
new file mode 100644
index 0000000..024f045
--- /dev/null
+++ b/src/components/Certificates/Add/__snapshots__/DownloadCerts.test.jsx.snap
@@ -0,0 +1,41 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DownloadCerts Render 1`] = `
+
+
+
+ Download Certifcates
+
+
+
+
+
+`;
diff --git a/src/components/Certificates/Add/index.jsx b/src/components/Certificates/Add/index.jsx
new file mode 100644
index 0000000..6cf9ed0
--- /dev/null
+++ b/src/components/Certificates/Add/index.jsx
@@ -0,0 +1,85 @@
+import React, { useEffect, useState } from "react";
+
+import {
+ useCertificateContext,
+ CertificateProvider,
+} from "../../Contexts/CertificateContext";
+import { useStatusContext } from "../../Contexts/StatusContext";
+import { useUserContext } from "../../Contexts/UserContext";
+import { logger } from "../../../services/monitoring";
+import CreateForm from "./CreateForm";
+import DownloadCerts from "./DownloadCerts";
+
+const VIEW_FORM = 0;
+const VIEW_DOWNLOAD = 1;
+
+const MainForm = () => {
+ const { busy, createCert } = useCertificateContext();
+ const { setMessage, setTitle, setSitePath } = useStatusContext();
+ const {
+ token: {
+ idToken: { jwtToken: token },
+ },
+ } = useUserContext();
+ const [view, setView] = useState(VIEW_FORM);
+ const [pubCert, setPubCert] = useState(null);
+ const [privCert, setPrivCert] = useState(null);
+ const [vin, setVIN] = useState(null);
+
+ useEffect(() => {
+ setTitle("Create Certificate");
+ setSitePath([
+ {
+ label: "Tools",
+ link: "/tools/certificates/add",
+ },
+ {
+ label: "Create Certificate",
+ },
+ ]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const onCreate = async (data) => {
+ try {
+ const result = await createCert(data, token);
+
+ setPubCert(result.public_key);
+ setPrivCert(result.private_key);
+ setVIN(data.vin);
+ setMessage(`Created ${data.vin} certificate`);
+ setView(VIEW_DOWNLOAD);
+ } catch (e) {
+ setMessage(e.message);
+ logger.warn(e.stack);
+ }
+ };
+
+ const onChangeView = () => {
+ setPubCert(null);
+ setPrivCert(null);
+ setVIN(null);
+
+ setView(VIEW_FORM);
+ };
+
+ if (view === VIEW_DOWNLOAD)
+ return (
+
+ );
+
+ return
;
+};
+
+const CertificateCreate = () => (
+
+
+
+);
+
+export default CertificateCreate;
diff --git a/src/components/Contexts/CertificateContext.jsx b/src/components/Contexts/CertificateContext.jsx
new file mode 100644
index 0000000..6f27396
--- /dev/null
+++ b/src/components/Contexts/CertificateContext.jsx
@@ -0,0 +1,50 @@
+import React, { useContext, useState } from "react";
+
+import api from "../../services/certificatesAPI";
+
+const CertificateContext = React.createContext();
+
+export const CertTypes = {
+ TREX: "TREX",
+ HMI: "HMI",
+ Charging: "CHARGE",
+};
+
+const validateCreate = (data) => {
+ if (!data.type) throw new Error("type is required");
+ if (!data.vin) throw new Error("vin is required");
+};
+
+export const CertificateProvider = ({ children }) => {
+ const [busy, setBusy] = useState(false);
+
+ const createCert = async (data, token) => {
+ try {
+ setBusy(true);
+
+ validateCreate(data);
+
+ const result = await api.create(data, token);
+ if (result.error) {
+ throw new Error(`Create certificate error. ${result.message}`);
+ }
+
+ return result;
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useCertificateContext = () => useContext(CertificateContext);
diff --git a/src/components/Controls/DownloadFileLink/__snapshots__/index.test.jsx.snap b/src/components/Controls/DownloadFileLink/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000..cdcdea8
--- /dev/null
+++ b/src/components/Controls/DownloadFileLink/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DownloadFileLink Render 1`] = `
+
+`;
diff --git a/src/components/Controls/DownloadFileLink/index.jsx b/src/components/Controls/DownloadFileLink/index.jsx
new file mode 100644
index 0000000..1ebfd90
--- /dev/null
+++ b/src/components/Controls/DownloadFileLink/index.jsx
@@ -0,0 +1,32 @@
+import React, { useEffect, useState } from "react";
+
+const DownloadFileLink = ({ data, filename, mimetype }) => {
+ const [link, setLink] = useState("");
+
+ const releaseLink = () => {
+ if (link === "") return;
+ URL.revokeObjectURL(link);
+ };
+
+ const makeFile = () => {
+ const file = new Blob([data], { type: mimetype ?? "text/plain" });
+
+ releaseLink();
+ setLink(URL.createObjectURL(file));
+ };
+
+ useEffect(() => {
+ if (!data) return;
+ makeFile();
+ return releaseLink;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [data, filename, mimetype]);
+
+ return (
+
+ {filename}
+
+ );
+};
+
+export default DownloadFileLink;
diff --git a/src/components/Controls/DownloadFileLink/index.test.jsx b/src/components/Controls/DownloadFileLink/index.test.jsx
new file mode 100644
index 0000000..64fad29
--- /dev/null
+++ b/src/components/Controls/DownloadFileLink/index.test.jsx
@@ -0,0 +1,21 @@
+import React from "react";
+import { render, waitFor } from "@testing-library/react";
+
+import DownloadFileLink from ".";
+
+describe("DownloadFileLink", () => {
+ beforeAll(() => {
+ global.URL.createObjectURL = jest.fn();
+ global.URL.revokeObjectURL = jest.fn();
+ });
+
+ it("Render", async () => {
+ const { container } = render(
+
+ );
+ await waitFor(() => {
+ /* render */
+ });
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/components/Fleets/Status/CANFilters/Table/__snapshots__/index.test.jsx.snap b/src/components/Fleets/Status/CANFilters/Table/__snapshots__/index.test.jsx.snap
index b695e50..5822db7 100644
--- a/src/components/Fleets/Status/CANFilters/Table/__snapshots__/index.test.jsx.snap
+++ b/src/components/Fleets/Status/CANFilters/Table/__snapshots__/index.test.jsx.snap
@@ -195,6 +195,23 @@ exports[`FleetCANFiltersTable Render 1`] = `
/>
+
+
+
|
+
+
+
|
+
+
+
diff --git a/src/components/Fleets/Status/Vehicles/Table/__snapshots__/index.test.jsx.snap b/src/components/Fleets/Status/Vehicles/Table/__snapshots__/index.test.jsx.snap
index 2456483..ac685b2 100644
--- a/src/components/Fleets/Status/Vehicles/Table/__snapshots__/index.test.jsx.snap
+++ b/src/components/Fleets/Status/Vehicles/Table/__snapshots__/index.test.jsx.snap
@@ -153,7 +153,23 @@ exports[`FleetVehiclesTable Render 1`] = `
- No actions
+
+
+
|
- No actions
+
+
+
- No actions
+
+
+
diff --git a/src/components/Fleets/Status/__snapshots__/CANFiltersTab.test.jsx.snap b/src/components/Fleets/Status/__snapshots__/CANFiltersTab.test.jsx.snap
index 8bed748..5c29255 100644
--- a/src/components/Fleets/Status/__snapshots__/CANFiltersTab.test.jsx.snap
+++ b/src/components/Fleets/Status/__snapshots__/CANFiltersTab.test.jsx.snap
@@ -194,6 +194,23 @@ exports[`CANFiltersTab Render 1`] = `
/>
+
+
+
|
+
+
+
|
+
+
+
diff --git a/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap b/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap
index cea9afa..ccd495f 100644
--- a/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap
+++ b/src/components/Fleets/Status/__snapshots__/VehiclesTab.test.jsx.snap
@@ -152,7 +152,23 @@ exports[`VehiclesTab Render 1`] = `
- No actions
+
+
+
|
- No actions
+
+
+
- No actions
+
+
+
diff --git a/src/components/Layouts/SideMenu.jsx b/src/components/Layouts/SideMenu.jsx
index cde94da..77f2c49 100644
--- a/src/components/Layouts/SideMenu.jsx
+++ b/src/components/Layouts/SideMenu.jsx
@@ -1,10 +1,11 @@
import React from "react";
import { List } from "@material-ui/core";
import HomeIcon from "@material-ui/icons/Home";
-import DirectionsCarIcon from '@material-ui/icons/DirectionsCar';
+import DirectionsCarIcon from "@material-ui/icons/DirectionsCar";
import CommuteIcon from "@material-ui/icons/Commute";
import CloudDownloadIcon from "@material-ui/icons/CloudDownload";
import AssessmentIcon from "@material-ui/icons/Assessment";
+import BuildIcon from "@material-ui/icons/Build";
import ListItemLink from "../ListItemLink";
import ListItemExternalLink from "../ListItemExternalLink";
@@ -43,6 +44,19 @@ const menuData = [
icon:
,
roles: [Roles.READ, Roles.CREATE],
},
+ {
+ label: "Tools",
+ to: "/tools/certificates/add",
+ icon:
,
+ roles: [Roles.CERTIFICATES],
+ submenus: [
+ {
+ label: "Certificate",
+ to: "/tools/certificates/add",
+ roles: [Roles.CERTIFICATES],
+ },
+ ],
+ },
];
const MenuItem = ({ item, children }) => {
diff --git a/src/components/Layouts/__snapshots__/SideMenu.test.jsx.snap b/src/components/Layouts/__snapshots__/SideMenu.test.jsx.snap
index 387ba7f..d40f672 100644
--- a/src/components/Layouts/__snapshots__/SideMenu.test.jsx.snap
+++ b/src/components/Layouts/__snapshots__/SideMenu.test.jsx.snap
@@ -190,6 +190,70 @@ exports[`SideMenu Authenticated 1`] = `
/>
+
+
+
+
+
+
+ Tools
+
+
+
+
+
+
+