feat: POC for ACME done

This commit is contained in:
Sheen Capadngan
2025-05-16 02:58:05 +08:00
parent c2949964b3
commit 6d10afc9d2
28 changed files with 3519 additions and 273 deletions
+4 -4
View File
@@ -1,10 +1,10 @@
import { SshCaStatus } from "../sshCa";
import { SshCertTemplateStatus } from "../sshCertificateTemplates";
import { CaStatus, CaType } from "./enums";
import { CaStatus, InternalCaType } from "./enums";
export const caTypeToNameMap: { [K in CaType]: string } = {
[CaType.ROOT]: "Root",
[CaType.INTERMEDIATE]: "Intermediate"
export const caTypeToNameMap: { [K in InternalCaType]: string } = {
[InternalCaType.ROOT]: "Root",
[InternalCaType.INTERMEDIATE]: "Intermediate"
};
export const caStatusToNameMap: { [K in CaStatus]: string } = {
+9
View File
@@ -1,4 +1,9 @@
export enum CaType {
INTERNAL = "internal",
ACME = "acme"
}
export enum InternalCaType {
ROOT = "root",
INTERMEDIATE = "intermediate"
}
@@ -12,3 +17,7 @@ export enum CaStatus {
export enum CaRenewalType {
EXISTING = "existing"
}
export enum AcmeDnsProvider {
ROUTE53 = "route53"
}
+6 -2
View File
@@ -1,7 +1,8 @@
export { CaRenewalType, CaStatus, CaType } from "./enums";
export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums";
export {
useCreateCa,
useCreateCertificate,
useCreateUnifiedCa,
useDeleteCa,
useImportCaCertificate,
useRenewCa,
@@ -10,9 +11,12 @@ export {
} from "./mutations";
export {
useGetCaById,
useGetCaByTypeAndId,
useGetCaCert,
useGetCaCerts,
useGetCaCertTemplates,
useGetCaCrls,
useGetCaCsr
useGetCaCsr,
useListCasByProjectId,
useListCasByTypeAndProjectId
} from "./queries";
+20
View File
@@ -9,6 +9,7 @@ import {
TCreateCaDTO,
TCreateCertificateDTO,
TCreateCertificateResponse,
TCreateUnifiedCertificateAuthorityDTO,
TDeleteCaDTO,
TImportCaCertificateDTO,
TImportCaCertificateResponse,
@@ -16,9 +17,28 @@ import {
TRenewCaResponse,
TSignIntermediateDTO,
TSignIntermediateResponse,
TUnifiedCertificateAuthority,
TUpdateCaDTO
} from "./types";
export const useCreateUnifiedCa = () => {
const queryClient = useQueryClient();
return useMutation<TUnifiedCertificateAuthority, object, TCreateUnifiedCertificateAuthorityDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TUnifiedCertificateAuthority>(
`/api/v1/pki/ca/${body.type}`,
body
);
return data;
},
onSuccess: (_, { type, projectId }) => {
queryClient.invalidateQueries({
queryKey: caKeys.listCasByTypeAndProjectId(type, projectId)
});
}
});
};
export const useCreateCa = () => {
const queryClient = useQueryClient();
return useMutation<TCertificateAuthority, object, TCreateCaDTO>({
+46 -1
View File
@@ -3,10 +3,14 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TCertificateTemplate } from "../certificateTemplates/types";
import { TCertificateAuthority } from "./types";
import { CaType } from "./enums";
import { TCertificateAuthority, TUnifiedCertificateAuthority } from "./types";
export const caKeys = {
getCaById: (caId: string) => [{ caId }, "ca"],
getCaByTypeAndId: (type: CaType, caId: string) => [{ type, caId }, "ca"],
listCasByTypeAndProjectId: (type: CaType, projectId: string) => [{ type, projectId }, "cas"],
listCasByProjectId: (projectId: string) => [{ projectId }, "cas"],
getCaCerts: (caId: string) => [{ caId }, "ca-cert"],
getCaCrls: (caId: string) => [{ caId }, "ca-crls"],
getCaCert: (caId: string) => [{ caId }, "ca-cert"],
@@ -16,6 +20,47 @@ export const caKeys = {
getCaEstConfig: (caId: string) => [{ caId }, "ca-est-config"]
};
export const useGetCaByTypeAndId = (type: CaType, caId: string) => {
return useQuery({
queryKey: caKeys.getCaByTypeAndId(type, caId),
queryFn: async () => {
const {
data: { certificateAuthority }
} = await apiRequest.get<{ certificateAuthority: TUnifiedCertificateAuthority }>(
`/api/v1/pki/ca/${type}/${caId}`
);
return certificateAuthority;
},
enabled: Boolean(caId)
});
};
export const useListCasByTypeAndProjectId = (type: CaType, projectId: string) => {
return useQuery({
queryKey: caKeys.listCasByTypeAndProjectId(type, projectId),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateAuthorities: TUnifiedCertificateAuthority[];
}>(`/api/v1/pki/ca/${type}?projectId=${projectId}`);
return data.certificateAuthorities;
}
});
};
export const useListCasByProjectId = (projectId: string) => {
return useQuery({
queryKey: caKeys.listCasByProjectId(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateAuthorities: TUnifiedCertificateAuthority[];
}>(`/api/v2/pki/ca?projectId=${projectId}`);
return data.certificateAuthorities;
}
});
};
export const useGetCaById = (caId: string) => {
return useQuery({
queryKey: caKeys.getCaById(caId),
+50 -2
View File
@@ -1,11 +1,59 @@
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "../certificates/enums";
import { CaRenewalType, CaStatus, CaType } from "./enums";
import { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums";
export type TAcmeCertificateAuthority = {
id: string;
projectId: string;
type: CaType.ACME;
status: CaStatus;
name: string;
disableDirectIssuance: boolean;
configuration: {
dnsAppConnectionId: string;
dnsProvider: AcmeDnsProvider;
directoryUrl: string;
accountEmail: string;
};
};
export type TInternalCertificateAuthority = {
id: string;
projectId: string;
type: CaType.INTERNAL;
status: CaStatus;
name: string;
disableDirectIssuance: boolean;
configuration: {
type: InternalCaType;
friendlyName?: string;
commonName: string;
organization: string;
ou: string;
country: string;
province: string;
locality: string;
maxPathLength: number;
keyAlgorithm: CertKeyAlgorithm;
notAfter?: string;
notBefore?: string;
dn: string;
parentCaId?: string;
serialNumber: string;
activeCaCertId: string;
};
};
export type TUnifiedCertificateAuthority =
| TAcmeCertificateAuthority
| TInternalCertificateAuthority;
export type TCreateUnifiedCertificateAuthorityDTO = Omit<TUnifiedCertificateAuthority, "id">;
export type TCertificateAuthority = {
id: string;
parentCaId?: string;
projectId: string;
type: CaType;
type: InternalCaType;
status: CaStatus;
friendlyName: string;
organization: string;
@@ -6,7 +6,7 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { CaStatus, CaType, useGetCaById } from "@app/hooks/api";
import { CaStatus, InternalCaType, useGetCaById } from "@app/hooks/api";
import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -82,7 +82,7 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
</div>
</div>
</div>
{ca.type === CaType.INTERMEDIATE && ca.status !== CaStatus.PENDING_CERTIFICATE && (
{ca.type === InternalCaType.INTERMEDIATE && ca.status !== CaStatus.PENDING_CERTIFICATE && (
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Parent CA ID</p>
<div className="group flex align-top">
@@ -156,7 +156,7 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
colorSchema="primary"
type="submit"
onClick={() => {
if (ca.type === CaType.INTERMEDIATE && !ca.parentCaId) {
if (ca.type === InternalCaType.INTERMEDIATE && !ca.parentCaId) {
// intermediate CA with external parent CA
handlePopUpOpen("installCaCert", {
caId,
@@ -5,6 +5,7 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ExternalCaSection } from "./components/ExternalCaSection";
import { CaSection } from "./components";
export const CertificateAuthoritiesPage = () => {
@@ -25,6 +26,7 @@ export const CertificateAuthoritiesPage = () => {
a={ProjectPermissionSub.CertificateAuthorities}
>
<CaSection />
<ExternalCaSection />
</ProjectPermissionCan>
</div>
</div>
@@ -17,7 +17,7 @@ import {
// DatePicker
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { CaType, useCreateCa, useGetCaById, useUpdateCa } from "@app/hooks/api/ca";
import { InternalCaType, useCreateCa, useGetCaById, useUpdateCa } from "@app/hooks/api/ca";
import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants";
import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -35,7 +35,7 @@ const getDateTenYearsFromToday = () => {
const schema = z
.object({
type: z.enum([CaType.ROOT, CaType.INTERMEDIATE]),
type: z.enum([InternalCaType.ROOT, InternalCaType.INTERMEDIATE]),
friendlyName: z.string(),
organization: z.string(),
ou: z.string(),
@@ -63,8 +63,8 @@ type Props = {
};
const caTypes = [
{ label: "Root", value: CaType.ROOT },
{ label: "Intermediate", value: CaType.INTERMEDIATE }
{ label: "Root", value: InternalCaType.ROOT },
{ label: "Intermediate", value: InternalCaType.INTERMEDIATE }
];
export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
@@ -85,7 +85,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
type: CaType.ROOT,
type: InternalCaType.ROOT,
friendlyName: "",
organization: "",
ou: "",
@@ -119,7 +119,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
});
} else {
reset({
type: CaType.ROOT,
type: InternalCaType.ROOT,
friendlyName: "",
organization: "",
ou: "",
@@ -212,7 +212,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
name="type"
defaultValue={CaType.ROOT}
defaultValue={InternalCaType.ROOT}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="CA Type" errorText={error?.message} isError={Boolean(error)}>
<Select
@@ -231,7 +231,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
{caType === CaType.ROOT && (
{caType === InternalCaType.ROOT && (
<>
{/* <Controller
name="notAfter"
@@ -72,7 +72,7 @@ export const CaSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Certificate Authorities</p>
<p className="text-xl font-semibold text-mineshaft-100">Internal Certificate Authorities</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}
@@ -0,0 +1,350 @@
import { useEffect } 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,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Switch
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
AcmeDnsProvider,
CaStatus,
CaType,
useCreateUnifiedCa,
useGetCaById,
useUpdateCa
} from "@app/hooks/api/ca";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
type: z.enum([CaType.ACME]),
name: z.string(),
disableDirectIssuance: z.boolean(),
status: z.enum([CaStatus.ACTIVE, CaStatus.DISABLED]),
configuration: z.object({
dnsAppConnection: z.object({
id: z.string(),
name: z.string()
}),
dnsProvider: z.nativeEnum(AcmeDnsProvider),
directoryUrl: z.string(),
accountEmail: z.string()
})
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["ca"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void;
};
const caTypes = [{ label: "ACME", value: CaType.ACME }];
export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || "");
// SHEEN TODO: finish up CA management
const { mutateAsync: createMutateAsync } = useCreateUnifiedCa();
const { mutateAsync: updateMutateAsync } = useUpdateCa();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
watch
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
type: CaType.ACME,
name: "",
status: CaStatus.ACTIVE,
disableDirectIssuance: false,
configuration: {
dnsAppConnection: {
id: "",
name: ""
},
dnsProvider: AcmeDnsProvider.ROUTE53,
directoryUrl: "",
accountEmail: ""
}
}
});
const caType = watch("type");
const dnsProvider = watch("configuration.dnsProvider");
const { data: availableConnections, isPending } = useListAvailableAppConnections(
AppConnection.AWS,
{
enabled: dnsProvider === AcmeDnsProvider.ROUTE53
}
);
useEffect(() => {
if (ca) {
// reset({
// type: ca.type,
// name: ca.name,
// disableDirectIssuance: ca.disableDirectIssuance
// });
} else {
reset({
type: CaType.ACME,
name: "",
status: CaStatus.ACTIVE,
disableDirectIssuance: false,
configuration: {
dnsAppConnection: {
id: "",
name: ""
},
dnsProvider: AcmeDnsProvider.ROUTE53,
directoryUrl: "",
accountEmail: ""
}
});
}
}, [ca]);
const onFormSubmit = async ({
type,
name,
disableDirectIssuance,
status,
configuration
}: FormData) => {
try {
if (!currentWorkspace?.slug) return;
if (ca) {
// update
// await updateMutateAsync({
// projectSlug: currentWorkspace.slug,
// caId: ca.id,
// name,
// disableDirectIssuance,
// status
// });
} else {
// create
await createMutateAsync({
projectId: currentWorkspace.id,
name,
type,
status,
disableDirectIssuance,
configuration: {
...configuration,
dnsAppConnectionId: configuration.dnsAppConnection.id
}
});
}
reset();
handlePopUpToggle("ca", false);
createNotification({
text: `Successfully ${ca ? "updated" : "created"} CA`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create CA",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.ca?.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("ca", isOpen);
}}
>
<ModalContent title={`${ca ? "View" : "Create"} External CA`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{ca && (
<FormControl label="CA ID">
<Input value={ca.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
name="type"
defaultValue={CaType.ACME}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Type" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
{caTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="my-external-ca" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
{caType === CaType.ACME && (
<>
<Controller
control={control}
name="configuration.dnsProvider"
defaultValue={AcmeDnsProvider.ROUTE53}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="DNS Provider"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
<SelectItem
value={String(AcmeDnsProvider.ROUTE53)}
key={AcmeDnsProvider.ROUTE53}
>
Route53
</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`${dnsProvider === AcmeDnsProvider.ROUTE53 ? "Route53" : ""} requires an AWS App Connection. This can be created from the Organization Settings page.`}
isError={Boolean(error)}
errorText={error?.message}
label="DNS App Connection"
>
<FilterableSelect
value={value}
onChange={(newValue) => {
onChange(newValue);
}}
isLoading={isPending}
options={availableConnections}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
control={control}
name="configuration.dnsAppConnection"
/>
<Controller
control={control}
defaultValue=""
name="configuration.directoryUrl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Directory URL"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="https://acme-v02.api.letsencrypt.org/directory"
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="configuration.accountEmail"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Account Email"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="[email protected]" />
</FormControl>
)}
/>
</>
)}
<Controller
control={control}
name="disableDirectIssuance"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="my-8">
<Switch
id="is-active"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Disable Direct Issuance</p>
</Switch>
</FormControl>
);
}}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.ca?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("ca", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,126 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { CaStatus, useDeleteCa, useUpdateCa } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { ExternalCaModal } from "./ExternalCaModal";
import { ExternalCaTable } from "./ExternalCaTable";
export const ExternalCaSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync: deleteCa } = useDeleteCa();
const { mutateAsync: updateCa } = useUpdateCa();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"ca",
"deleteCa",
"caStatus", // enable / disable
"upgradePlan"
] as const);
const onRemoveCaSubmit = async (caId: string) => {
try {
if (!currentWorkspace?.slug) return;
await deleteCa({ caId, projectSlug: currentWorkspace.slug });
createNotification({
text: "Successfully deleted CA",
type: "success"
});
handlePopUpClose("deleteCa");
} catch {
createNotification({
text: "Failed to delete CA",
type: "error"
});
}
};
const onUpdateCaStatus = async ({ caId, status }: { caId: string; status: CaStatus }) => {
try {
if (!currentWorkspace?.slug) return;
await updateCa({ caId, projectSlug: currentWorkspace.slug, status });
createNotification({
text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`,
type: "success"
});
handlePopUpClose("caStatus");
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`,
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">External Certificate Authorities</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("ca")}
isDisabled={!isAllowed}
>
Create CA
</Button>
)}
</ProjectPermissionCan>
</div>
<ExternalCaModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<ExternalCaTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.deleteCa.isOpen}
title={`Are you sure want to remove the CA ${
(popUp?.deleteCa?.data as { dn: string })?.dn || ""
} from the project?`}
subTitle="This action will delete other CAs and certificates below it in your CA hierarchy."
onChange={(isOpen) => handlePopUpToggle("deleteCa", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)}
/>
<DeleteActionModal
isOpen={popUp.caStatus.isOpen}
title={`Are you sure want to ${
(popUp?.caStatus?.data as { status: string })?.status === CaStatus.ACTIVE
? "enable"
: "disable"
} the CA ${(popUp?.caStatus?.data as { dn: string })?.dn || ""} from the project?`}
subTitle={
(popUp?.caStatus?.data as { status: string })?.status === CaStatus.ACTIVE
? "This action will allow the CA to start issuing certificates again."
: "This action will prevent the CA from issuing new certificates."
}
onChange={(isOpen) => handlePopUpToggle("caStatus", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus })
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};
@@ -0,0 +1,165 @@
import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { CaStatus, CaType, useListCasByTypeAndProjectId } from "@app/hooks/api";
import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["ca", "deleteCa", "caStatus", "upgradePlan"]>,
data?: {
caId?: string;
dn?: string;
status?: CaStatus;
description?: string;
}
) => void;
};
export const ExternalCaTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useListCasByTypeAndProjectId(CaType.ACME, currentWorkspace.id);
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Type</Th>
<Th>Status</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="project-cas" />}
{!isPending &&
data &&
data.length > 0 &&
data.map((ca) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`ca-${ca.id}`}
onClick={() =>
navigate({
to: `/${ProjectType.CertificateManager}/$projectId/ca/$caId` as const,
params: {
projectId: currentWorkspace.id,
caId: ca.id
}
})
}
>
<Td>{ca.name}</Td>
<Td>{ca.type}</Td>
<Td>
<Badge variant={getCaStatusBadgeVariant(ca.status)}>
{caStatusToNameMap[ca.status]}
</Badge>
</Td>
<Td className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
{(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("caStatus", {
caId: ca.id,
status:
ca.status === CaStatus.ACTIVE
? CaStatus.DISABLED
: CaStatus.ACTIVE
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
{`${ca.status === CaStatus.ACTIVE ? "Disable" : "Enable"} CA`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
// handlePopUpOpen("deleteCa", {
// caId: ca.id,
// dn: ca.dn
// });
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete CA
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data?.length === 0 && (
<EmptyState
title="No external certificate authorities have been created"
icon={faCertificate}
/>
)}
</TableContainer>
</div>
);
};
@@ -20,10 +20,10 @@ import {
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
CaStatus,
CaType,
useCreatePkiSubscriber,
useGetPkiSubscriber,
useListWorkspaceCas,
useListCasByProjectId,
useListWorkspacePkiSubscribers,
useUpdatePkiSubscriber
} from "@app/hooks/api";
@@ -74,10 +74,7 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace.id;
const { data: subscribers } = useListWorkspacePkiSubscribers(projectId);
const { data: cas } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? "",
status: CaStatus.ACTIVE
});
const { data: cas } = useListCasByProjectId(projectId);
const { data: pkiSubscriber } = useGetPkiSubscriber({
subscriberName:
@@ -276,11 +273,16 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(cas || []).map(({ id, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{dn}
</SelectItem>
))}
{(cas || []).map(({ id, name, type, configuration }) => {
const displayName =
type === CaType.INTERNAL ? `${name} (${configuration.dn})` : name;
return (
<SelectItem value={id} key={`ca-${id}`}>
{displayName}
</SelectItem>
);
})}
</Select>
</FormControl>
)}