From 3b2173a0988975ddc8202a6629ecda32bcc37830 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 3 Dec 2024 18:36:32 -0800 Subject: [PATCH] Add issue SSH certificate modal --- frontend/src/hooks/api/ssh-ca/enums.tsx | 5 + frontend/src/hooks/api/ssh-ca/index.tsx | 2 +- frontend/src/hooks/api/ssh-ca/mutations.tsx | 12 +- frontend/src/hooks/api/ssh-ca/types.ts | 19 +- .../src/views/Org/SshCaPage/SshCaPage.tsx | 10 +- .../components/SshCertificateContent.tsx | 174 +++++++++++ .../components/SshCertificateModal.tsx | 274 ++++++++++++++++++ .../SshCertificateTemplatesSection.tsx | 5 +- .../SshCertificateTemplatesTable.tsx | 26 +- .../views/Org/SshCaPage/components/index.tsx | 1 + 10 files changed, 513 insertions(+), 15 deletions(-) create mode 100644 frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx create mode 100644 frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx diff --git a/frontend/src/hooks/api/ssh-ca/enums.tsx b/frontend/src/hooks/api/ssh-ca/enums.tsx index 859890bbf..3b5e949a9 100644 --- a/frontend/src/hooks/api/ssh-ca/enums.tsx +++ b/frontend/src/hooks/api/ssh-ca/enums.tsx @@ -2,3 +2,8 @@ export enum SshCaStatus { ACTIVE = "active", DISABLED = "disabled" } + +export enum SshCertType { + USER = "user", + HOST = "host" +} diff --git a/frontend/src/hooks/api/ssh-ca/index.tsx b/frontend/src/hooks/api/ssh-ca/index.tsx index 58ed4cd73..70727225d 100644 --- a/frontend/src/hooks/api/ssh-ca/index.tsx +++ b/frontend/src/hooks/api/ssh-ca/index.tsx @@ -1,3 +1,3 @@ export { SshCaStatus } from "./enums"; -export { useCreateSshCa, useDeleteSshCa,useUpdateSshCa } from "./mutations"; +export { useCreateSshCa, useDeleteSshCa, useIssueSshCreds,useUpdateSshCa } from "./mutations"; export { useGetSshCaById, useGetSshCaCertTemplates } from "./queries"; diff --git a/frontend/src/hooks/api/ssh-ca/mutations.tsx b/frontend/src/hooks/api/ssh-ca/mutations.tsx index 4d3dcc736..9b2fa65e3 100644 --- a/frontend/src/hooks/api/ssh-ca/mutations.tsx +++ b/frontend/src/hooks/api/ssh-ca/mutations.tsx @@ -1,4 +1,3 @@ - import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; @@ -7,6 +6,8 @@ import { organizationKeys } from "../organization/queries"; import { TCreateSshCaDTO, TDeleteSshCaDTO, + TIssueSshCredsDTO, + TIssueSshCredsResponse, TSshCertificateAuthority, TUpdateSshCaDTO} from "./types"; @@ -59,3 +60,12 @@ export const useDeleteSshCa = () => { } }); }; + +export const useIssueSshCreds = () => { + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post("/api/v1/ssh/issue", body); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts index a1539ef31..f263bb489 100644 --- a/frontend/src/hooks/api/ssh-ca/types.ts +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -1,5 +1,5 @@ import { CertKeyAlgorithm } from "../certificates/enums"; -import { SshCaStatus } from "./enums"; +import { SshCaStatus, SshCertType } from "./enums"; export type TSshCertificateAuthority = { id: string; @@ -26,3 +26,20 @@ export type TUpdateSshCaDTO = { export type TDeleteSshCaDTO = { caId: string; }; + +export type TIssueSshCredsDTO = { + templateName: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +}; + +export type TIssueSshCredsResponse = { + serialNumber: string; + signedKey: string; + privateKey: string; + publicKey: string; + keyAlgorithm: CertKeyAlgorithm; +}; diff --git a/frontend/src/views/Org/SshCaPage/SshCaPage.tsx b/frontend/src/views/Org/SshCaPage/SshCaPage.tsx index e0619d5a4..34a869faa 100644 --- a/frontend/src/views/Org/SshCaPage/SshCaPage.tsx +++ b/frontend/src/views/Org/SshCaPage/SshCaPage.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan, ProjectPermissionCan } from "@app/components/permissions"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, @@ -15,13 +15,7 @@ import { DropdownMenuTrigger, Tooltip } from "@app/components/v2"; -import { - OrgPermissionActions, - OrgPermissionSubjects, - ProjectPermissionActions, - ProjectPermissionSub, - useOrganization -} from "@app/context"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { withPermission } from "@app/hoc"; import { useDeleteSshCa, useGetSshCaById } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx new file mode 100644 index 000000000..a71a2601d --- /dev/null +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx @@ -0,0 +1,174 @@ +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import FileSaver from "file-saver"; + +import { IconButton, Tooltip } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; + +type Props = { + serialNumber: string; + signedKey: string; + privateKey: string; + publicKey: string; +}; + +export const SshCertificateContent = ({ + serialNumber, + signedKey, + privateKey, + publicKey +}: Props) => { + const [copyTextSerialNumber, isCopyingSerialNumber, setCopyTextSerialNumber] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + const [copyTextCertificate, isCopyingCertificate, setCopyTextCertificate] = useTimedReset( + { + initialState: "Copy to clipboard" + } + ); + + const [copyTextCertificateSk, isCopyingCertificateSk, setCopyTextCertificateSk] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const [copyTextCertificatePk, isCopyingCertificatePk, setCopyTextCertificatePk] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + + return ( +
+

Serial Number

+
+

{serialNumber}

+ + { + navigator.clipboard.writeText(serialNumber); + setCopyTextSerialNumber("Copied"); + }} + > + + + +
+
+

SSH Certificate / Signed Key

+
+ + { + navigator.clipboard.writeText(signedKey); + setCopyTextCertificate("Copied"); + }} + > + + + + + { + downloadTxtFile("user_key-cert.pub", signedKey); + }} + > + + + +
+
+
+

{signedKey}

+
+ {privateKey && ( + <> +
+

Private Key

+
+ + { + navigator.clipboard.writeText(privateKey); + setCopyTextCertificateSk("Copied"); + }} + > + + + + + { + downloadTxtFile("user_key", privateKey); + }} + > + + + +
+
+
+

{privateKey}

+
+ + )} + {publicKey && ( + <> +
+

Public Key

+
+ + { + navigator.clipboard.writeText(publicKey); + setCopyTextCertificatePk("Copied"); + }} + > + + + + + { + downloadTxtFile("user_key.pub", publicKey); + }} + > + + + +
+
+
+

{publicKey}

+
+ + )} +
+ ); +}; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx new file mode 100644 index 000000000..c5ac70e1d --- /dev/null +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx @@ -0,0 +1,274 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useGetSshCaCertTemplates,useIssueSshCreds } from "@app/hooks/api"; +import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; +import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; +import { SshCertType } from "@app/hooks/api/ssh-ca/enums"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { SshCertificateContent } from "./SshCertificateContent"; + +/** + * // NOTE (dangtony98): current UI only supports SSH certificate + * issuance via /issue endpoint but should extend to also support + * /sign endpoint as this is already supported in the backend + */ + +const schema = z.object({ + templateName: z.string(), + keyAlgorithm: z.enum([ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.RSA_4096, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384 + ]), + certType: z.nativeEnum(SshCertType), + principals: z.string(), + ttl: z.string().optional(), + keyId: z.string().optional() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["sshCertificate"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshCertificate"]>, state?: boolean) => void; +}; + +type TSshCertificateDetails = { + serialNumber: string; + privateKey: string; + publicKey: string; + signedKey: string; +}; + +export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { + const [certificateDetails, setCertificateDetails] = useState(null); + const { mutateAsync: issueSshCreds } = useIssueSshCreds(); + + const popUpData = popUp?.sshCertificate?.data as { sshCaId: string; templateName: string }; + + const { data: templatesData } = useGetSshCaCertTemplates(popUpData?.sshCaId || ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + keyAlgorithm: CertKeyAlgorithm.RSA_2048, + certType: SshCertType.USER + } + }); + + useEffect(() => { + if (popUpData) { + setValue("templateName", popUpData.templateName); + } + }, [popUpData]); + + const onFormSubmit = async ({ + templateName, + keyAlgorithm, + certType, + principals, + ttl, + keyId + }: FormData) => { + try { + const { serialNumber, publicKey, privateKey, signedKey } = await issueSshCreds({ + templateName, + keyAlgorithm, + certType, + principals: principals.split(",").map((user) => user.trim()), + ttl, + keyId + }); + + reset(); + + setCertificateDetails({ + serialNumber, + privateKey, + publicKey, + signedKey + }); + + createNotification({ + text: "Successfully created SSH certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create SSH certificate", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("sshCertificate", isOpen); + reset(); + setCertificateDetails(null); + }} + > + + {!certificateDetails ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx index ecf19d22f..75db07ff6 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx @@ -4,10 +4,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { DeleteActionModal, IconButton } from "@app/components/v2"; -import { OrgPermissionSshCertificateTemplateActions,OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteSshCertTemplate } from "@app/hooks/api"; +import { SshCertificateModal } from "./SshCertificateModal"; import { SshCertificateTemplateModal } from "./SshCertificateTemplateModal"; import { SshCertificateTemplatesTable } from "./SshCertificateTemplatesTable"; @@ -18,6 +19,7 @@ type Props = { export const SshCertificateTemplatesSection = ({ caId }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "sshCertificateTemplate", + "sshCertificate", "deleteSshCertificateTemplate", "upgradePlan" ] as const); @@ -69,6 +71,7 @@ export const SshCertificateTemplatesSection = ({ caId }: Props) => {
+ , data?: { id?: string; name?: string; + sshCaId?: string; + templateName?: string; } ) => void; }; @@ -66,6 +68,24 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props + + { + handlePopUpOpen("sshCertificate", { + sshCaId, + templateName: certificateTemplate.name + }); + }} + icon={ + + } + > + Issue SSH Certificate + +