Add issue SSH certificate modal

This commit is contained in:
Tuan Dang
2024-12-03 18:36:32 -08:00
parent 07d9398aad
commit 3b2173a098
10 changed files with 513 additions and 15 deletions

View File

@@ -2,3 +2,8 @@ export enum SshCaStatus {
ACTIVE = "active",
DISABLED = "disabled"
}
export enum SshCertType {
USER = "user",
HOST = "host"
}

View File

@@ -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";

View File

@@ -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<TIssueSshCredsResponse, {}, TIssueSshCredsDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TIssueSshCredsResponse>("/api/v1/ssh/issue", body);
return data;
}
});
};

View File

@@ -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;
};

View File

@@ -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";

View File

@@ -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<string>({
initialState: "Copy to clipboard"
});
const [copyTextCertificate, isCopyingCertificate, setCopyTextCertificate] = useTimedReset<string>(
{
initialState: "Copy to clipboard"
}
);
const [copyTextCertificateSk, isCopyingCertificateSk, setCopyTextCertificateSk] =
useTimedReset<string>({
initialState: "Copy to clipboard"
});
const [copyTextCertificatePk, isCopyingCertificatePk, setCopyTextCertificatePk] =
useTimedReset<string>({
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 (
<div>
<h2 className="mb-4">Serial Number</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{serialNumber}</p>
<Tooltip content={copyTextSerialNumber}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(serialNumber);
setCopyTextSerialNumber("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingSerialNumber ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
<div className="mb-4 flex items-center justify-between">
<h2>SSH Certificate / Signed Key</h2>
<div className="flex">
<Tooltip content={copyTextCertificate}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(signedKey);
setCopyTextCertificate("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingCertificate ? faCheck : faCopy} />
</IconButton>
</Tooltip>
<Tooltip content="Download">
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("user_key-cert.pub", signedKey);
}}
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Tooltip>
</div>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{signedKey}</p>
</div>
{privateKey && (
<>
<div className="mb-4 flex items-center justify-between">
<h2>Private Key</h2>
<div className="flex">
<Tooltip content={copyTextCertificateSk}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setCopyTextCertificateSk("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingCertificateSk ? faCheck : faCopy} />
</IconButton>
</Tooltip>
<Tooltip content={copyTextCertificateSk}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("user_key", privateKey);
}}
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Tooltip>
</div>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{privateKey}</p>
</div>
</>
)}
{publicKey && (
<>
<div className="mb-4 flex items-center justify-between">
<h2>Public Key</h2>
<div className="flex">
<Tooltip content={copyTextCertificatePk}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(publicKey);
setCopyTextCertificatePk("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingCertificatePk ? faCheck : faCopy} />
</IconButton>
</Tooltip>
<Tooltip content={copyTextCertificatePk}>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("user_key.pub", publicKey);
}}
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Tooltip>
</div>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{publicKey}</p>
</div>
</>
)}
</div>
);
};

View File

@@ -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<typeof schema>;
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<TSshCertificateDetails | null>(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<FormData>({
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 (
<Modal
isOpen={popUp?.sshCertificate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("sshCertificate", isOpen);
reset();
setCertificateDetails(null);
}}
>
<ModalContent title="Issue SSH Certificate">
{!certificateDetails ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="templateName"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Certificate Template"
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled
>
{(templatesData?.certificateTemplates || []).map(({ id, name }) => (
<SelectItem value={name} key={`ssh-cert-template-${id}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="principals"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Principal(s)"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="ec2-user" />
</FormControl>
)}
/>
<Controller
control={control}
name="certType"
defaultValue={SshCertType.USER}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Certificate Type"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
<SelectItem value={SshCertType.USER}>User</SelectItem>
<SelectItem value={SshCertType.HOST}>Host</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="keyAlgorithm"
defaultValue={CertKeyAlgorithm.RSA_2048}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Key Algorithm"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{certKeyAlgorithms.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl label="TTL" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
</FormControl>
)}
/>
<Controller
control={control}
name="keyId"
render={({ field, fieldState: { error } }) => (
<FormControl label="Key ID" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="12345678" />
</FormControl>
)}
/>
<div className="mt-4 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("sshCertificate", false)}
>
Cancel
</Button>
</div>
</form>
) : (
<SshCertificateContent
serialNumber={certificateDetails.serialNumber}
signedKey={certificateDetails.signedKey}
publicKey={certificateDetails.publicKey}
privateKey={certificateDetails.privateKey}
/>
)}
</ModalContent>
</Modal>
);
};

View File

@@ -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) => {
<div className="py-4">
<SshCertificateTemplatesTable handlePopUpOpen={handlePopUpOpen} sshCaId={caId} />
</div>
<SshCertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<SshCertificateTemplateModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}

View File

@@ -1,4 +1,4 @@
import { faEllipsis, faFileAlt, faTrash } from "@fortawesome/free-solid-svg-icons";
import { faCertificate,faEllipsis, faFileAlt, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
@@ -19,7 +19,7 @@ import {
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionSshCertificateTemplateActions,OrgPermissionSubjects } from "@app/context";
import { OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects } from "@app/context";
import { useGetSshCaCertTemplates } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -27,11 +27,13 @@ type Props = {
sshCaId: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
["sshCertificateTemplate", "deleteSshCertificateTemplate", "upgradePlan"]
["sshCertificateTemplate", "sshCertificate", "deleteSshCertificateTemplate", "upgradePlan"]
>,
data?: {
id?: string;
name?: string;
sshCaId?: string;
templateName?: string;
}
) => void;
};
@@ -66,6 +68,24 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionSshCertificateTemplateActions.Edit}
a={OrgPermissionSubjects.SshCertificateTemplates}
>
<DropdownMenuItem
onClick={() => {
handlePopUpOpen("sshCertificate", {
sshCaId,
templateName: certificateTemplate.name
});
}}
icon={
<FontAwesomeIcon icon={faCertificate} size="sm" className="mr-1" />
}
>
Issue SSH Certificate
</DropdownMenuItem>
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionSshCertificateTemplateActions.Edit}
a={OrgPermissionSubjects.SshCertificateTemplates}

View File

@@ -1,2 +1,3 @@
export { SshCaDetailsSection } from "./SshCaDetailsSection";
export { SshCertificateModal } from "./SshCertificateModal";
export { SshCertificateTemplatesSection } from "./SshCertificateTemplatesSection";