Merge branch 'main' into ENG-3955

This commit is contained in:
x032205
2025-10-21 20:30:09 -04:00
438 changed files with 25651 additions and 5604 deletions
@@ -13,8 +13,8 @@ export const AccessManagementPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Access Control" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Access Control"
@@ -13,8 +13,8 @@ export const AuthenticationPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Authentication"
@@ -13,8 +13,8 @@ export const CachingPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Caching"
@@ -13,8 +13,8 @@ export const EncryptionPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Encryption"
@@ -13,8 +13,8 @@ export const EnvironmentPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Environment Variables"
@@ -15,8 +15,8 @@ export const GeneralPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="General"
@@ -13,8 +13,8 @@ export const IntegrationsPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Integrations"
@@ -13,18 +13,24 @@ export const ResourceOverviewPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "Resource Overview" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="instance"
title="Resource Overview"
description="Manage resources within your Infisical instance."
/>
<Tabs defaultValue="tab-organizations">
<Tabs orientation="vertical" defaultValue="tab-organizations">
<TabList>
<Tab value="tab-organizations">Organizations</Tab>
<Tab value="tab-users">Users</Tab>
<Tab value="tab-identities">Identities</Tab>
<Tab variant="instance" value="tab-organizations">
Organizations
</Tab>
<Tab variant="instance" value="tab-users">
Users
</Tab>
<Tab variant="instance" value="tab-identities">
Identities
</Tab>
</TabList>
<TabPanel value="tab-organizations">
<OrganizationsTable />
@@ -4,19 +4,20 @@ import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { PkiAlertsSection } from "./components";
export const AlertingPage = () => {
const { t } = useTranslation();
return (
<div className="container mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Alerting" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Alerting"
description="Configure alerts for expiring certificates and CAs to maintain security and compliance."
/>
@@ -1,5 +1,7 @@
import { Helmet } from "react-helmet";
import { useNavigate, useParams } from "@tanstack/react-router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -18,6 +20,7 @@ import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
import { CaType, useDeleteCa, useGetCa } from "@app/hooks/api";
import { TInternalCertificateAuthority } from "@app/hooks/api/ca/types";
import { ProjectType } from "@app/hooks/api/projects/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { CaInstallCertModal } from "../CertificateAuthoritiesPage/components/CaInstallCertModal";
@@ -84,10 +87,24 @@ const Page = () => {
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader scope="project" title={data.name}>
<div className="mx-auto mb-6 w-full max-w-8xl">
<Link
to="/projects/cert-management/$projectId/certificate-authorities"
params={{
projectId
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Certificate Authorities
</Link>
<PageHeader
scope={ProjectType.CertificateManager}
description="Manage certificate authority"
title={data.name}
>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { ExternalCaSection } from "./components/ExternalCaSection";
import { CaSection } from "./components";
@@ -11,13 +12,13 @@ import { CaSection } from "./components";
export const CertificateAuthoritiesPage = () => {
const { t } = useTranslation();
return (
<div className="container mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificate Authorities" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Certificate Authorities"
description="Manage certificate authorities for issuing and signing certificates"
/>
@@ -12,8 +12,7 @@ import {
Modal,
ModalContent,
Select,
SelectItem,
Switch
SelectItem
// DatePicker
} from "@app/components/v2";
import { useProject } from "@app/context";
@@ -60,12 +59,7 @@ const schema = z
commonName: z.string(),
notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }),
maxPathLength: z.string(),
keyAlgorithm: z.enum([
CertKeyAlgorithm.RSA_2048,
CertKeyAlgorithm.RSA_4096,
CertKeyAlgorithm.ECDSA_P256,
CertKeyAlgorithm.ECDSA_P384
])
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm)
})
.required()
})
@@ -145,7 +139,11 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
maxPathLength: ca.configuration.maxPathLength
? String(ca.configuration.maxPathLength)
: "",
keyAlgorithm: ca.configuration.keyAlgorithm
keyAlgorithm: Object.values(CertKeyAlgorithm).includes(
ca.configuration.keyAlgorithm as CertKeyAlgorithm
)
? ca.configuration.keyAlgorithm
: CertKeyAlgorithm.RSA_2048
}
});
} else {
@@ -153,7 +151,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
type: CaType.INTERNAL,
name: "",
status: CaStatus.ACTIVE,
enableDirectIssuance: true,
enableDirectIssuance: false,
configuration: {
type: InternalCaType.ROOT,
organization: "",
@@ -457,23 +455,6 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
name="enableDirectIssuance"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="my-8">
<Switch
id="enable-direct-issuance"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Enable Direct Issuance</p>
</Switch>
</FormControl>
);
}}
/>
<div className="flex items-center">
<Button
className="mr-4"
@@ -9,6 +9,7 @@ import {
ProjectPermissionSub,
useProjectPermission
} from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { PkiCollectionSection } from "../AlertingPage/components";
import { CertificatesSection } from "./components";
@@ -27,13 +28,13 @@ export const CertificatesPage = () => {
);
return (
<div className="container mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificates" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Certificates"
description="View and track issued certificates, monitor expiration dates, and manage certificate lifecycles."
/>
@@ -0,0 +1,96 @@
import { Control, Controller } from "react-hook-form";
import { FormControl, Select, SelectItem } from "@app/components/v2";
type AlgorithmOption = {
value: string;
label: string;
};
type AlgorithmSelectorsProps = {
control: Control<any>;
availableSignatureAlgorithms: AlgorithmOption[];
availableKeyAlgorithms: AlgorithmOption[];
signatureError?: string;
keyError?: string;
};
export const AlgorithmSelectors = ({
control,
availableSignatureAlgorithms,
availableKeyAlgorithms,
signatureError,
keyError
}: AlgorithmSelectorsProps) => {
return (
<div className="grid grid-cols-2 gap-4">
<div>
<Controller
control={control}
name="signatureAlgorithm"
render={({ field: { onChange, ...field } }) => (
<FormControl
label="Signature Algorithm"
errorText={signatureError}
isError={Boolean(signatureError)}
isRequired
>
<Select
defaultValue=""
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
placeholder={
availableSignatureAlgorithms.length > 0
? "Select signature algorithm"
: "No algorithms available"
}
position="popper"
>
{availableSignatureAlgorithms.map((algorithm) => (
<SelectItem key={algorithm.value} value={algorithm.value}>
{algorithm.label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="keyAlgorithm"
render={({ field: { onChange, ...field } }) => (
<FormControl
label="Key Algorithm"
errorText={keyError}
isError={Boolean(keyError)}
isRequired
>
<Select
defaultValue=""
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
placeholder={
availableKeyAlgorithms.length > 0
? "Select key algorithm"
: "No algorithms available"
}
position="popper"
>
{availableKeyAlgorithms.map((algorithm) => (
<SelectItem key={algorithm.value} value={algorithm.value}>
{algorithm.label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
</div>
);
};
@@ -7,7 +7,6 @@ import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
@@ -24,8 +23,6 @@ const schema = z.object({
certificatePem: z.string().trim().min(1, "Certificate PEM is required"),
privateKeyPem: z.string().trim().min(1, "Private Key PEM is required"),
chainPem: z.string().trim().min(1, "Certificate Chain PEM is required"),
friendlyName: z.string(),
collectionId: z.string().optional()
});
@@ -72,7 +69,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
certificatePem,
privateKeyPem,
chainPem,
friendlyName,
collectionId
}: FormData) => {
try {
@@ -84,8 +80,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
certificatePem,
privateKeyPem,
chainPem,
friendlyName,
pkiCollectionId: collectionId
});
@@ -150,20 +144,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="My Certificate" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
@@ -0,0 +1,526 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
Button,
FormControl,
FormLabel,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import { useProject } from "@app/context";
import { useGetCert } from "@app/hooks/api";
import { useCreateCertificateV3 } from "@app/hooks/api/ca";
import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
import { AlgorithmSelectors } from "./AlgorithmSelectors";
import { CertificateContent } from "./CertificateContent";
import { filterUsages, formatSubjectAltNames, getAttributeValue } from "./certificateUtils";
import { KeyUsageSection } from "./KeyUsageSection";
import { SubjectAltNamesField } from "./SubjectAltNamesField";
import { useCertificateTemplate } from "./useCertificateTemplate";
const createSchema = (shouldShowSubjectSection: boolean) => {
return z.object({
profileId: z.string().min(1, "Profile is required"),
subjectAttributes: shouldShowSubjectSection
? z
.array(
z.object({
type: z.enum(["common_name"]),
value: z.string().min(1, "Value is required")
})
)
.min(1, "At least one subject attribute is required")
: z
.array(
z.object({
type: z.enum(["common_name"]),
value: z.string().min(1, "Value is required")
})
)
.optional(),
subjectAltNames: z
.array(
z.object({
type: z.nativeEnum(CertSubjectAlternativeNameType),
value: z.string().min(1, "Value is required")
})
)
.default([]),
ttl: z.string().trim().min(1, "TTL is required"),
signatureAlgorithm: z.string().min(1, "Signature algorithm is required"),
keyAlgorithm: z.string().min(1, "Key algorithm is required"),
keyUsages: z
.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
[CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
[CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
[CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
[CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
[CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
[CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
})
.default({}),
extendedKeyUsages: z
.object({
[CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
[CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
[CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
})
.default({})
});
};
export type FormData = z.infer<ReturnType<typeof createSchema>>;
type Props = {
popUp: UsePopUpState<["certificateIssuance"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["certificateIssuance"]>,
state?: boolean
) => void;
profileId?: string;
};
type TCertificateDetails = {
serialNumber: string;
certificate: string;
certificateChain: string;
privateKey: string;
};
export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => {
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
const { currentProject } = useProject();
const inputSerialNumber =
(popUp?.certificateIssuance?.data as { serialNumber: string })?.serialNumber || "";
const sanitizedSerialNumber = inputSerialNumber.replace(/[^a-fA-F0-9:]/g, "");
const { data: cert } = useGetCert(sanitizedSerialNumber);
const { data: profilesData } = useListCertificateProfiles({
projectId: currentProject?.id || "",
includeMetrics: false,
enrollmentType: "api"
});
const { mutateAsync: createCertificate } = useCreateCertificateV3();
const selectedProfileId = useMemo(() => {
const form = document.querySelector('select[name="profileId"]') as HTMLSelectElement;
return form?.value || profileId || "";
}, [profileId]);
const selectedProfile = useMemo(
() => profilesData?.certificateProfiles?.find((p) => p.id === selectedProfileId),
[profilesData?.certificateProfiles, selectedProfileId]
);
const { data: templateData } = useGetCertificateTemplateV2ById({
templateId: selectedProfile?.certificateTemplateId || ""
});
const {
control,
handleSubmit,
reset,
watch,
setValue,
formState,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(createSchema((templateData?.subject?.length || 0) > 0)),
defaultValues: {
profileId: profileId || "",
subjectAttributes: [],
subjectAltNames: [],
ttl: "30d",
signatureAlgorithm: "",
keyAlgorithm: "",
keyUsages: {},
extendedKeyUsages: {}
}
});
const actualSelectedProfileId = watch("profileId");
const actualSelectedProfile = useMemo(
() => profilesData?.certificateProfiles?.find((p) => p.id === actualSelectedProfileId),
[profilesData?.certificateProfiles, actualSelectedProfileId]
);
const {
constraints,
filteredKeyUsages,
filteredExtendedKeyUsages,
availableSignatureAlgorithms,
availableKeyAlgorithms,
resetConstraints
} = useCertificateTemplate(
templateData,
actualSelectedProfile,
popUp?.certificateIssuance?.isOpen || false,
setValue,
watch
);
const resetAllState = useCallback(() => {
setCertificateDetails(null);
resetConstraints();
reset();
}, [reset, resetConstraints]);
useEffect(() => {
if (cert) {
const subjectAttrs: Array<{ type: "common_name"; value: string }> = [];
if (cert.commonName)
subjectAttrs.push({ type: "common_name" as const, value: cert.commonName });
reset({
profileId: "",
subjectAttributes:
subjectAttrs.length > 0 ? subjectAttrs : [{ type: "common_name" as const, value: "" }],
subjectAltNames: cert.subjectAltNames
? cert.subjectAltNames.split(",").map((name) => {
const trimmed = name.trim();
if (trimmed.includes("@"))
return { type: CertSubjectAlternativeNameType.EMAIL, value: trimmed };
if (trimmed.match(/^\d+\.\d+\.\d+\.\d+$/))
return { type: CertSubjectAlternativeNameType.IP_ADDRESS, value: trimmed };
if (trimmed.startsWith("http"))
return { type: CertSubjectAlternativeNameType.URI, value: trimmed };
return { type: CertSubjectAlternativeNameType.DNS_NAME, value: trimmed };
})
: [],
ttl: "",
signatureAlgorithm: "",
keyAlgorithm: "",
keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])),
extendedKeyUsages: Object.fromEntries(
(cert.extendedKeyUsages || []).map((name) => [name, true])
)
});
}
}, [cert, reset]);
useEffect(() => {
if (popUp?.certificateIssuance?.isOpen && profileId && !cert) {
setValue("profileId", profileId);
}
}, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]);
const onFormSubmit = useCallback(
async ({
profileId: formProfileId,
subjectAttributes,
subjectAltNames,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages,
extendedKeyUsages
}: FormData) => {
try {
if (!currentProject?.slug) {
createNotification({
text: "Project not found. Please refresh and try again.",
type: "error"
});
return;
}
if (!formProfileId) {
createNotification({
text: "Please select a certificate profile.",
type: "error"
});
return;
}
let commonName = "";
if (
constraints.shouldShowSubjectSection &&
subjectAttributes &&
subjectAttributes.length > 0
) {
commonName = getAttributeValue(subjectAttributes, "common_name");
if (!commonName.trim()) {
createNotification({
text: "Common name is required.",
type: "error"
});
return;
}
}
const certificateRequest: any = {
profileId: formProfileId,
projectSlug: currentProject.slug,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
};
if (constraints.shouldShowSubjectSection && commonName) {
certificateRequest.commonName = commonName;
}
if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) {
const formattedSans = formatSubjectAltNames(subjectAltNames);
if (formattedSans && formattedSans.length > 0) {
certificateRequest.altNames = formattedSans;
}
}
const { serialNumber, certificate, certificateChain, privateKey } =
await createCertificate(certificateRequest);
setCertificateDetails({
serialNumber,
certificate,
certificateChain,
privateKey
});
createNotification({
text: "Successfully created certificate",
type: "success"
});
} catch (err) {
console.error("Certificate creation failed:", err);
const errorMessage =
err instanceof Error
? err.message
: "An unexpected error occurred while creating the certificate";
createNotification({
text: `Failed to create certificate: ${errorMessage}`,
type: "error"
});
}
},
[
currentProject?.slug,
createCertificate,
constraints.shouldShowSubjectSection,
constraints.shouldShowSanSection
]
);
const getModalTitle = () => {
if (certificateDetails) return "Certificate Created Successfully";
if (cert) return "Certificate Details";
return "Issue New Certificate";
};
const getModalSubTitle = () => {
if (certificateDetails) return "Certificate has been successfully created and is ready for use";
if (cert) return "View certificate information";
return "Issue a new certificate using a certificate profile";
};
return (
<Modal
isOpen={popUp?.certificateIssuance?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificateIssuance", isOpen);
if (!isOpen) {
resetAllState();
}
}}
>
<ModalContent title={getModalTitle()} subTitle={getModalSubTitle()}>
{certificateDetails && (
<CertificateContent
serialNumber={certificateDetails.serialNumber}
certificate={certificateDetails.certificate}
certificateChain={certificateDetails.certificateChain}
privateKey={certificateDetails.privateKey}
/>
)}
{cert && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-mineshaft-300">Certificate Details</h4>
<p className="text-sm text-mineshaft-400">Serial Number: {cert.serialNumber}</p>
<p className="text-sm text-mineshaft-400">Common Name: {cert.commonName}</p>
<p className="text-sm text-mineshaft-400">Status: {cert.status}</p>
</div>
</div>
)}
{!cert && !certificateDetails && (
<form onSubmit={handleSubmit(onFormSubmit)}>
{!profileId && (
<Controller
control={control}
name="profileId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={
<div>
<FormLabel
isRequired
label="Certificate Profile"
icon={
<Tooltip
className="text-center"
content={
<span>
Certificate profiles define the policies and enrollment methods
for certificate issuance. The selected profile will enforce
validation rules and determine the CA used for signing.
</span>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" />
</Tooltip>
}
/>
</div>
}
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Select
defaultValue=""
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
placeholder="Select a certificate profile"
position="popper"
>
{profilesData?.certificateProfiles?.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.slug}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
{(actualSelectedProfile || profileId) && (
<>
{constraints.shouldShowSubjectSection && (
<Controller
control={control}
name="subjectAttributes"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Common Name"
isRequired
errorText={error?.message}
isError={Boolean(error)}
>
<Input
value={value?.[0]?.value || ""}
onChange={(e) => {
onChange([{ type: "common_name", value: e.target.value }]);
}}
placeholder="example.com"
/>
</FormControl>
)}
/>
)}
{constraints.shouldShowSanSection && (
<SubjectAltNamesField
control={control}
allowedSanTypes={constraints.allowedSanTypes}
error={formState.errors.subjectAltNames?.message}
/>
)}
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Time to Live (TTL)"
isRequired
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="30d, 1y, 8760h" />
</FormControl>
)}
/>
<AlgorithmSelectors
control={control}
availableSignatureAlgorithms={availableSignatureAlgorithms}
availableKeyAlgorithms={availableKeyAlgorithms}
signatureError={formState.errors.signatureAlgorithm?.message}
keyError={formState.errors.keyAlgorithm?.message}
/>
<Accordion type="single" collapsible className="w-full">
<KeyUsageSection
control={control}
title="Key Usages"
accordionValue="key-usages"
namePrefix="keyUsages"
options={filteredKeyUsages}
requiredUsages={constraints.requiredKeyUsages}
/>
<KeyUsageSection
control={control}
title="Extended Key Usages"
accordionValue="extended-key-usages"
namePrefix="extendedKeyUsages"
options={filteredExtendedKeyUsages}
requiredUsages={constraints.requiredExtendedKeyUsages}
/>
</Accordion>
</>
)}
<div className="mt-7 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || (!actualSelectedProfile && !profileId)}
>
{cert ? "Update" : "Issue Certificate"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => {
handlePopUpToggle("certificateIssuance", false);
}}
>
Cancel
</Button>
</div>
</form>
)}
</ModalContent>
</Modal>
);
};
@@ -46,9 +46,8 @@ const schema = z.object({
certificateTemplateId: z.string().optional(),
caId: z.string(),
collectionId: z.string().optional(),
friendlyName: z.string(),
commonName: z.string().trim().min(1),
altNames: z.string(),
subjectAltNames: z.string(),
ttl: z.string().trim(),
keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
@@ -139,9 +138,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
if (cert) {
reset({
caId: cert.caId,
friendlyName: cert.friendlyName,
commonName: cert.commonName,
altNames: cert.altNames,
subjectAltNames: cert.subjectAltNames,
certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE,
ttl: "",
keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])),
@@ -152,9 +150,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
} else {
reset({
caId: "",
friendlyName: "",
commonName: "",
altNames: "",
subjectAltNames: "",
ttl: "",
certificateTemplateId: CERT_TEMPLATE_NONE_VALUE,
keyUsages: {
@@ -182,10 +179,9 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
const onFormSubmit = async ({
caId,
friendlyName,
collectionId,
commonName,
altNames,
subjectAltNames,
ttl,
keyUsages,
extendedKeyUsages
@@ -198,9 +194,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined,
projectSlug: currentProject.slug,
pkiCollectionId: collectionId,
friendlyName,
commonName,
altNames,
subjectAltNames,
ttl,
keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value)
@@ -359,20 +354,6 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
/>
</>
)}
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="My Certificate" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
@@ -391,7 +372,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
defaultValue=""
name="altNames"
name="subjectAltNames"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Alternative Names (SANs)"
@@ -7,22 +7,28 @@ import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionCertificateActions,
ProjectPermissionSub,
useProject
useProject,
useSubscription
} from "@app/context";
import { useDeleteCert } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateImportModal } from "./CertificateImportModal";
import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
import { CertificateModal } from "./CertificateModal";
import { CertificateRevocationModal } from "./CertificateRevocationModal";
import { CertificatesTable } from "./CertificatesTable";
export const CertificatesSection = () => {
const { currentProject } = useProject();
const { subscription } = useSubscription();
const { mutateAsync: deleteCert } = useDeleteCert();
const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates;
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"certificateIssuance",
"certificate",
"certificateImport",
"certificateCert",
@@ -73,7 +79,9 @@ export const CertificatesSection = () => {
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificate")}
onClick={() =>
handlePopUpOpen(isLegacyTemplatesEnabled ? "certificate" : "certificateIssuance")
}
isDisabled={!isAllowed}
>
Issue
@@ -83,7 +91,11 @@ export const CertificatesSection = () => {
</ProjectPermissionCan>
</div>
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
{isLegacyTemplatesEnabled ? (
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
) : (
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
)}
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
@@ -0,0 +1,78 @@
import { Control, Controller } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
Checkbox,
FormLabel
} from "@app/components/v2";
type KeyUsageOption = {
label: string;
value: string;
};
type KeyUsageSectionProps = {
control: Control<any>;
title: string;
accordionValue: string;
namePrefix: "keyUsages" | "extendedKeyUsages";
options: KeyUsageOption[];
requiredUsages: string[];
};
export const KeyUsageSection = ({
control,
title,
accordionValue,
namePrefix,
options,
requiredUsages
}: KeyUsageSectionProps) => {
if (options.length === 0) return null;
return (
<AccordionItem value={accordionValue}>
<AccordionTrigger>{title}</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-2">
{options.map(({ label, value }) => {
const isRequired = requiredUsages.includes(value);
return (
<Controller
key={label}
control={control}
name={`${namePrefix}.${value}` as any}
render={({ field }) => (
<div className="flex items-center space-x-3">
<Checkbox
id={`${namePrefix}-${value}`}
isChecked={field.value || false}
onCheckedChange={(checked) => {
if (!isRequired) {
field.onChange(checked);
}
}}
isDisabled={isRequired}
/>
<div className="flex items-center gap-2">
<FormLabel
id={`${namePrefix}-${value}`}
className={`text-sm ${
isRequired ? "text-mineshaft-200" : "cursor-pointer text-mineshaft-300"
}`}
label={label}
/>
{isRequired && <span className="text-xs">(Required)</span>}
</div>
</div>
)}
/>
);
})}
</div>
</AccordionContent>
</AccordionItem>
);
};
@@ -0,0 +1,99 @@
import { Control, Controller } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
import { getSanPlaceholder, getSanTypeLabels, SubjectAltName } from "./certificateUtils";
type SubjectAltNamesFieldProps = {
control: Control<any>;
allowedSanTypes: CertSubjectAlternativeNameType[];
error?: string;
};
export const SubjectAltNamesField = ({
control,
allowedSanTypes,
error
}: SubjectAltNamesFieldProps) => {
const sanTypeLabels = getSanTypeLabels();
return (
<Controller
control={control}
name="subjectAltNames"
render={({ field: { onChange, value } }) => (
<FormControl
label="Subject Alternative Names (SANs)"
errorText={error}
isError={Boolean(error)}
>
<div className="space-y-2">
{value.map((san: SubjectAltName, index: number) => (
// eslint-disable-next-line react/no-array-index-key
<div key={`subject-alt-name-${index}`} className="flex items-center gap-2">
<Select
value={san.type}
onValueChange={(newType) => {
const newValue = [...value];
newValue[index] = {
...san,
type: newType as CertSubjectAlternativeNameType
};
onChange(newValue);
}}
className="w-24"
>
{allowedSanTypes.map((sanType) => (
<SelectItem key={sanType} value={sanType}>
{sanTypeLabels[sanType]}
</SelectItem>
))}
</Select>
<Input
value={san.value}
onChange={(e) => {
const newValue = [...value];
newValue[index] = { ...san, value: e.target.value };
onChange(newValue);
}}
placeholder={getSanPlaceholder(san.type)}
className="flex-1"
/>
<IconButton
ariaLabel="Remove SAN"
variant="plain"
size="sm"
onClick={() => {
const newValue = value.filter((_: any, i: number) => i !== index);
onChange(newValue);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<Button
type="button"
variant="outline_bg"
size="xs"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
const defaultType =
allowedSanTypes.length > 0
? allowedSanTypes[0]
: CertSubjectAlternativeNameType.DNS_NAME;
onChange([...value, { type: defaultType, value: "" }]);
}}
className="w-full"
>
Add SAN
</Button>
</div>
</FormControl>
)}
/>
);
};
@@ -0,0 +1,51 @@
import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
export const getSanPlaceholder = (sanType: CertSubjectAlternativeNameType): string => {
switch (sanType) {
case CertSubjectAlternativeNameType.DNS_NAME:
return "example.com or *.example.com";
case CertSubjectAlternativeNameType.IP_ADDRESS:
return "192.168.1.1";
case CertSubjectAlternativeNameType.EMAIL:
return "[email protected]";
case CertSubjectAlternativeNameType.URI:
return "https://example.com";
default:
return "Enter value";
}
};
export const getSanTypeLabels = () => ({
[CertSubjectAlternativeNameType.DNS_NAME]: "DNS",
[CertSubjectAlternativeNameType.IP_ADDRESS]: "IP",
[CertSubjectAlternativeNameType.EMAIL]: "Email",
[CertSubjectAlternativeNameType.URI]: "URI"
});
export type SubjectAltName = {
type: CertSubjectAlternativeNameType;
value: string;
};
export const formatSubjectAltNames = (subjectAltNames: SubjectAltName[]) => {
return subjectAltNames
.filter((san) => san.value.trim())
.map((san) => ({
type: san.type,
value: san.value.trim()
}));
};
export const filterUsages = <T extends Record<string, boolean>>(usages: T): string[] => {
return Object.entries(usages)
.filter(([, value]) => value)
.map(([key]) => key);
};
export const getAttributeValue = (
subjectAttributes: Array<{ type: string; value: string }> | undefined,
type: string
): string => {
const foundAttr = subjectAttributes?.find((attr) => attr.type === type);
return foundAttr?.value || "";
};
@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from "react";
import { UseFormSetValue, UseFormWatch } from "react-hook-form";
import {
EXTENDED_KEY_USAGES_OPTIONS,
KEY_USAGES_OPTIONS
} from "@app/hooks/api/certificates/constants";
import {
CertSubjectAlternativeNameType,
mapTemplateKeyAlgorithmToApi,
mapTemplateSignatureAlgorithmToApi
} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
export type TemplateConstraints = {
allowedKeyUsages: string[];
allowedExtendedKeyUsages: string[];
requiredKeyUsages: string[];
requiredExtendedKeyUsages: string[];
allowedSignatureAlgorithms: string[];
allowedKeyAlgorithms: string[];
allowedSanTypes: CertSubjectAlternativeNameType[];
shouldShowSanSection: boolean;
shouldShowSubjectSection: boolean;
};
export const useCertificateTemplate = (
templateData: any,
selectedProfile: any,
isModalOpen: boolean,
setValue: UseFormSetValue<any>,
watch: UseFormWatch<any>
) => {
const [constraints, setConstraints] = useState<TemplateConstraints>({
allowedKeyUsages: [],
allowedExtendedKeyUsages: [],
requiredKeyUsages: [],
requiredExtendedKeyUsages: [],
allowedSignatureAlgorithms: [],
allowedKeyAlgorithms: [],
allowedSanTypes: [
CertSubjectAlternativeNameType.DNS_NAME,
CertSubjectAlternativeNameType.IP_ADDRESS,
CertSubjectAlternativeNameType.EMAIL,
CertSubjectAlternativeNameType.URI
],
shouldShowSanSection: true,
shouldShowSubjectSection: true
});
const filteredKeyUsages = useMemo(() => {
return KEY_USAGES_OPTIONS.filter(({ value }) => constraints.allowedKeyUsages.includes(value));
}, [constraints.allowedKeyUsages]);
const filteredExtendedKeyUsages = useMemo(() => {
return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) =>
constraints.allowedExtendedKeyUsages.includes(value)
);
}, [constraints.allowedExtendedKeyUsages]);
const availableSignatureAlgorithms = useMemo(() => {
return constraints.allowedSignatureAlgorithms.map((templateAlgorithm) => {
const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm);
return {
value: apiAlgorithm,
label: apiAlgorithm
};
});
}, [constraints.allowedSignatureAlgorithms]);
const availableKeyAlgorithms = useMemo(() => {
return constraints.allowedKeyAlgorithms.map((templateAlgorithm) => {
const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm);
return {
value: apiAlgorithm,
label: apiAlgorithm
};
});
}, [constraints.allowedKeyAlgorithms]);
const resetConstraints = () => {
setConstraints({
allowedKeyUsages: [],
allowedExtendedKeyUsages: [],
requiredKeyUsages: [],
requiredExtendedKeyUsages: [],
allowedSignatureAlgorithms: [],
allowedKeyAlgorithms: [],
allowedSanTypes: [
CertSubjectAlternativeNameType.DNS_NAME,
CertSubjectAlternativeNameType.IP_ADDRESS,
CertSubjectAlternativeNameType.EMAIL,
CertSubjectAlternativeNameType.URI
],
shouldShowSanSection: true,
shouldShowSubjectSection: true
});
};
useEffect(() => {
if (templateData && selectedProfile && isModalOpen) {
const newConstraints: TemplateConstraints = {
allowedSignatureAlgorithms: templateData.algorithms?.signature || [],
allowedKeyAlgorithms: templateData.algorithms?.keyAlgorithm || [],
allowedKeyUsages: [
...(templateData.keyUsages?.required || []),
...(templateData.keyUsages?.allowed || [])
],
allowedExtendedKeyUsages: [
...(templateData.extendedKeyUsages?.required || []),
...(templateData.extendedKeyUsages?.allowed || [])
],
requiredKeyUsages: templateData.keyUsages?.required || [],
requiredExtendedKeyUsages: templateData.extendedKeyUsages?.required || [],
allowedSanTypes: [],
shouldShowSanSection: true,
shouldShowSubjectSection: true
};
// Set TTL if available
if (templateData.validity?.max) {
setValue("ttl", templateData.validity.max);
}
// Handle SAN types
if (templateData.sans && templateData.sans.length > 0) {
const sanTypes: CertSubjectAlternativeNameType[] = [];
templateData.sans.forEach((sanPolicy: any) => {
if (!sanTypes.includes(sanPolicy.type)) {
sanTypes.push(sanPolicy.type);
}
});
newConstraints.allowedSanTypes = sanTypes;
newConstraints.shouldShowSanSection = true;
} else {
newConstraints.allowedSanTypes = [];
newConstraints.shouldShowSanSection = false;
setValue("subjectAltNames", []);
}
// Handle subject section
if (templateData.subject && templateData.subject.length > 0) {
newConstraints.shouldShowSubjectSection = true;
const currentSubjectAttrs = watch("subjectAttributes");
if (!currentSubjectAttrs || currentSubjectAttrs.length === 0) {
setValue("subjectAttributes", [{ type: "common_name", value: "" }]);
}
} else {
newConstraints.shouldShowSubjectSection = false;
setValue("subjectAttributes", undefined);
}
setConstraints(newConstraints);
// Set initial required usages
const initialKeyUsages: Record<string, boolean> = {};
const initialExtendedKeyUsages: Record<string, boolean> = {};
(templateData.keyUsages?.required || []).forEach((usage: string) => {
initialKeyUsages[usage] = true;
});
(templateData.extendedKeyUsages?.required || []).forEach((usage: string) => {
initialExtendedKeyUsages[usage] = true;
});
setValue("keyUsages", initialKeyUsages);
setValue("extendedKeyUsages", initialExtendedKeyUsages);
}
}, [templateData, selectedProfile, setValue, watch, isModalOpen]);
return {
constraints,
filteredKeyUsages,
filteredExtendedKeyUsages,
availableSignatureAlgorithms,
availableKeyAlgorithms,
resetConstraints
};
};
@@ -7,6 +7,7 @@ import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionSub, useProject } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
import { ProjectType } from "@app/hooks/api/projects/types";
import { IntegrationsListPageTabs } from "@app/types/integrations";
import { PkiSyncsTab } from "./components";
@@ -42,16 +43,18 @@ export const IntegrationsListPage = () => {
<meta property="og:title" content="Manage your certificates in seconds" />
<meta name="og:description" content="Sync and manage PKI certificates across services" />
</Helmet>
<div className="relative container mx-auto max-w-7xl pb-12 text-white">
<div className="relative mx-auto max-w-8xl pb-12 text-white">
<div className="mb-8">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Integrations"
description="Manage integrations with third-party certificate services."
/>
<Tabs value={currentTab} onValueChange={updateSelectedTab}>
<Tabs orientation="vertical" value={currentTab} onValueChange={updateSelectedTab}>
<TabList>
<Tab value={IntegrationsListPageTabs.PkiSyncs}>Certificate Syncs</Tab>
<Tab variant="project" value={IntegrationsListPageTabs.PkiSyncs}>
Certificate Syncs
</Tab>
</TabList>
<TabPanel value={IntegrationsListPageTabs.PkiSyncs}>
<ProjectPermissionCan
@@ -1,6 +1,8 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -19,6 +21,7 @@ import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
import { useDeletePkiCollection, useGetPkiCollectionById } from "@app/hooks/api";
import { PkiItemType } from "@app/hooks/api/pkiCollections/constants";
import { ProjectType } from "@app/hooks/api/projects/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { PkiCollectionModal } from "../AlertingPage/components/PkiCollectionModal";
@@ -70,10 +73,24 @@ export const PkiCollectionPage = () => {
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader scope="project" title={data.name}>
<div className="mx-auto mb-6 w-full max-w-8xl">
<Link
to="/projects/cert-management/$projectId/certificates"
params={{
projectId
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Certificates
</Link>
<PageHeader
scope={ProjectType.CertificateManager}
title={data.name}
description="Manage certificate collection"
>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -1,6 +1,8 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -22,6 +24,7 @@ import {
useProject
} from "@app/context";
import { useDeletePkiSubscriber, useGetPkiSubscriber } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/projects/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { PkiSubscriberModal } from "../PkiSubscribersPage/components/PkiSubscriberModal";
@@ -75,10 +78,24 @@ const Page = () => {
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader scope="project" title={data.name}>
<div className="mx-auto mb-6 w-full max-w-8xl">
<Link
to="/projects/cert-management/$projectId/subscribers"
params={{
projectId
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Subscribers
</Link>
<PageHeader
scope={ProjectType.CertificateManager}
title={data.name}
description="Manage PKI subscriber"
>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -2,6 +2,7 @@ import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { ProjectType } from "@app/hooks/api/projects/types";
import { PkiSubscriberSection } from "./components";
@@ -13,10 +14,10 @@ export const PkiSubscribersPage = () => {
<title>{t("common.head-title", { title: "PKI Subscribers" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Subscribers"
description="Manage subscribers that request and receive certificates, including user devices, servers, and services."
/>
@@ -7,7 +7,8 @@ import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
useProject
useProject,
useSubscription
} from "@app/context";
import { useDeletePkiSubscriber, useUpdatePkiSubscriber } from "@app/hooks/api";
import { PkiSubscriberStatus } from "@app/hooks/api/pkiSubscriber/types";
@@ -18,7 +19,10 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable";
export const PkiSubscriberSection = () => {
const { currentProject } = useProject();
const { subscription } = useSubscription();
const projectId = currentProject.id;
const canCreateLegacySubscribers = subscription.pkiLegacyTemplates;
const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber();
const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber();
@@ -100,23 +104,25 @@ export const PkiSubscriberSection = () => {
/>
</span>
</a>
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Create}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("pkiSubscriber")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Subscriber
</Button>
)}
</ProjectPermissionCan>
{canCreateLegacySubscribers && (
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Create}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("pkiSubscriber")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Subscriber
</Button>
)}
</ProjectPermissionCan>
)}
</div>
</div>
<PkiSubscribersTable handlePopUpOpen={handlePopUpOpen} />
@@ -68,8 +68,8 @@ const PageContent = () => {
return (
<>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 font-inter text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 font-inter text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<Button
variant="link"
type="submit"
@@ -48,6 +48,7 @@ import {
import { usePopUp } from "@app/hooks";
import { useDeleteCertTemplateV2 } from "@app/hooks/api";
import { useListCertificateTemplates } from "@app/hooks/api/certificateTemplates/queries";
import { ProjectType } from "@app/hooks/api/projects/types";
import { CertificateTemplateEnrollmentModal } from "../CertificatesPage/components/CertificateTemplateEnrollmentModal";
import { PkiTemplateForm } from "./components/PkiTemplateForm";
@@ -58,6 +59,7 @@ export const PkiTemplateListPage = () => {
const { currentProject } = useProject();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"certificateTemplate",
"deleteTemplate",
@@ -103,37 +105,39 @@ export const PkiTemplateListPage = () => {
<title>{t("common.head-title", { title: "PKI Templates" })}</title>
</Helmet>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.CertificateManager}
title="Certificate Templates"
description="Manage certificate template to request and issue dynamic certificates following a strict format."
/>
</div>
<div className="container mx-auto mb-6 max-w-7xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Templates</p>
<div className="flex w-full justify-end">
<ProjectPermissionCan
I={ProjectPermissionPkiTemplateActions.Create}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificateTemplate")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Template
</Button>
)}
</ProjectPermissionCan>
<div className="container mx-auto mb-6 max-w-8xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
{subscription?.pkiLegacyTemplates && (
<div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Templates</p>
<div className="flex w-full justify-end">
<ProjectPermissionCan
I={ProjectPermissionPkiTemplateActions.Create}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificateTemplate")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Template
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
</div>
)}
<TableContainer>
<Table>
<THead>
@@ -267,7 +271,7 @@ export const PkiTemplateListPage = () => {
onDeleteApproved={() => onRemovePkiSubscriberSubmit()}
/>
</div>
<div className="container mx-auto max-w-7xl" />
<div className="container mx-auto max-w-8xl" />
</div>
<Modal
isOpen={popUp?.certificateTemplate?.isOpen}
@@ -0,0 +1,83 @@
import { useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { CertificateProfilesTab } from "./components/CertificateProfilesTab";
import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab";
enum TabSections {
CertificateProfiles = "profiles",
CertificateTemplatesV2 = "templates-v2"
}
export const PoliciesPage = () => {
const { t } = useTranslation();
const { currentProject } = useProject();
const [activeTab, setActiveTab] = useState(TabSections.CertificateProfiles);
if (!currentProject) {
return <ContentLoader />;
}
return (
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => {
if (!isAllowed) {
return (
<div className="mx-auto flex h-full flex-col justify-center bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl text-center">
<p>You don&apos;t have permission to access certificate policies.</p>
</div>
</div>
);
}
return (
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificate Policies" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope={ProjectType.CertificateManager}
title="Certificate Policies"
description="Manage certificate templates and profiles for unified certificate issuance"
/>
<Tabs
orientation="vertical"
value={activeTab}
onValueChange={(value) => setActiveTab(value as TabSections)}
>
<TabList>
<Tab variant="project" value={TabSections.CertificateProfiles}>
Certificate Profiles
</Tab>
<Tab variant="project" value={TabSections.CertificateTemplatesV2}>
Certificate Templates
</Tab>
</TabList>
<TabPanel value={TabSections.CertificateProfiles}>
<CertificateProfilesTab />
</TabPanel>
<TabPanel value={TabSections.CertificateTemplatesV2}>
<CertificateTemplatesV2Tab />
</TabPanel>
</Tabs>
</div>
</div>
);
}}
</ProjectPermissionCan>
);
};
@@ -0,0 +1,127 @@
import { useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
import { useProjectPermission } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import {
TCertificateProfileWithDetails,
useDeleteCertificateProfile
} from "@app/hooks/api/certificateProfiles";
import { CreateProfileModal } from "./CreateProfileModal";
import { ProfileList } from "./ProfileList";
export const CertificateProfilesTab = () => {
const { permission } = useProjectPermission();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedProfile, setSelectedProfile] = useState<TCertificateProfileWithDetails | null>(
null
);
const deleteProfile = useDeleteCertificateProfile();
const canCreateProfile = permission.can(
ProjectPermissionActions.Create,
ProjectPermissionSub.CertificateAuthorities
);
const handleCreateProfile = () => {
setIsCreateModalOpen(true);
};
const handleEditProfile = (profile: TCertificateProfileWithDetails) => {
setSelectedProfile(profile);
setIsEditModalOpen(true);
};
const handleDeleteProfile = (profile: TCertificateProfileWithDetails) => {
setSelectedProfile(profile);
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (!selectedProfile) return;
try {
await deleteProfile.mutateAsync({
profileId: selectedProfile.id
});
setIsDeleteModalOpen(false);
setSelectedProfile(null);
createNotification({
text: `Certificate profile "${selectedProfile.slug}" deleted successfully`,
type: "success"
});
} catch (error) {
console.error(
`Failed to delete profile "${selectedProfile.slug}" (ID: ${selectedProfile.id}):`,
error
);
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-mineshaft-100">Certificate Profiles</h2>
<p className="text-sm text-bunker-300">
Unified certificate issuance configurations combining CA, template, and enrollment
method
</p>
</div>
{canCreateProfile && (
<Button
colorSchema="primary"
type="button"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleCreateProfile}
>
Create Profile
</Button>
)}
</div>
<ProfileList onEditProfile={handleEditProfile} onDeleteProfile={handleDeleteProfile} />
<CreateProfileModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
{selectedProfile && (
<>
<CreateProfileModal
isOpen={isEditModalOpen}
onClose={() => {
setIsEditModalOpen(false);
setSelectedProfile(null);
}}
profile={selectedProfile}
mode="edit"
/>
<DeleteActionModal
isOpen={isDeleteModalOpen}
title={`Delete Certificate Profile ${selectedProfile.slug}?`}
onChange={(isOpen) => {
setIsDeleteModalOpen(isOpen);
if (!isOpen) {
setSelectedProfile(null);
}
}}
deleteKey={selectedProfile.slug}
onDeleteApproved={handleDeleteConfirm}
/>
</>
)}
</div>
);
};
@@ -0,0 +1,609 @@
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,
Checkbox,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useProject } from "@app/context";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import {
TCertificateProfileWithDetails,
TCreateCertificateProfileDTO,
TUpdateCertificateProfileDTO,
useCreateCertificateProfile,
useUpdateCertificateProfile
} from "@app/hooks/api/certificateProfiles";
import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
const createSchema = z
.object({
slug: z
.string()
.trim()
.min(1, "Profile slug is required")
.max(255, "Profile slug must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Profile slug must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
enrollmentType: z.enum(["api", "est"]),
certificateAuthorityId: z.string().min(1, "Certificate Authority is required"),
certificateTemplateId: z.string().min(1, "Certificate Template is required"),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().optional(),
passphrase: z.string().min(1, "EST passphrase is required"),
caChain: z.string().min(1, "EST CA chain is required").optional()
})
.refine(
(data) => {
if (!data.disableBootstrapCaValidation && !data.caChain) {
return false;
}
return true;
},
{
message: "EST CA chain is required when bootstrap CA validation is enabled",
path: ["caChain"]
}
)
.optional(),
apiConfig: z
.object({
autoRenew: z.boolean().optional(),
autoRenewDays: z.number().min(1).max(365).optional()
})
.optional()
})
.refine(
(data) => {
if (data.enrollmentType === "est" && !data.estConfig) {
return false;
}
if (data.enrollmentType === "api" && !data.apiConfig) {
return false;
}
return true;
},
{
message: "Configuration is required for selected enrollment type"
}
);
const editSchema = z
.object({
slug: z
.string()
.trim()
.min(1, "Profile slug is required")
.max(255, "Profile slug must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Profile slug must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
enrollmentType: z.enum(["api", "est"]),
certificateAuthorityId: z.string().optional(),
certificateTemplateId: z.string().optional(),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().optional(),
passphrase: z.string().optional(),
caChain: z.string().optional()
})
.optional(),
apiConfig: z
.object({
autoRenew: z.boolean().optional(),
autoRenewDays: z.number().min(1).max(365).optional()
})
.optional()
})
.refine(
(data) => {
if (data.enrollmentType === "est" && !data.estConfig) {
return false;
}
if (data.enrollmentType === "api" && !data.apiConfig) {
return false;
}
return true;
},
{
message: "Configuration is required for selected enrollment type"
}
);
export type FormData = z.infer<typeof createSchema>;
interface Props {
isOpen: boolean;
onClose: () => void;
profile?: TCertificateProfileWithDetails;
mode?: "create" | "edit";
}
export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }: Props) => {
const { currentProject } = useProject();
const { data: caData } = useListCasByProjectId(currentProject?.id || "");
const { data: templateData } = useListCertificateTemplatesV2({
projectId: currentProject?.id || "",
limit: 100,
offset: 0
});
const createProfile = useCreateCertificateProfile();
const updateProfile = useUpdateCertificateProfile();
const isEdit = mode === "edit" && profile;
const certificateAuthorities = caData || [];
const certificateTemplates = templateData?.certificateTemplates || [];
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(isEdit ? editSchema : createSchema),
defaultValues: isEdit
? {
slug: profile.slug,
description: profile.description || "",
enrollmentType: profile.enrollmentType,
certificateAuthorityId: profile.caId,
certificateTemplateId: profile.certificateTemplateId,
estConfig:
profile.enrollmentType === "est"
? {
disableBootstrapCaValidation:
profile.estConfig?.disableBootstrapCaValidation || false,
passphrase: profile.estConfig?.passphrase || "",
caChain: profile.estConfig?.caChain || ""
}
: undefined,
apiConfig:
profile.enrollmentType === "api"
? {
autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
}
: undefined
}
: {
slug: "",
description: "",
enrollmentType: "api",
certificateAuthorityId: "",
certificateTemplateId: "",
apiConfig: {
autoRenew: false,
autoRenewDays: 30
}
}
});
const watchedEnrollmentType = watch("enrollmentType");
const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation");
const watchedAutoRenew = watch("apiConfig.autoRenew");
useEffect(() => {
if (isEdit && profile) {
reset({
slug: profile.slug,
description: profile.description || "",
enrollmentType: profile.enrollmentType,
certificateAuthorityId: profile.caId,
certificateTemplateId: profile.certificateTemplateId,
estConfig:
profile.enrollmentType === "est"
? {
disableBootstrapCaValidation:
profile.estConfig?.disableBootstrapCaValidation || false,
passphrase: profile.estConfig?.passphrase || "",
caChain: profile.estConfig?.caChain || ""
}
: undefined,
apiConfig:
profile.enrollmentType === "api"
? {
autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
}
: undefined
});
}
}, [isEdit, profile, reset]);
const onFormSubmit = async (data: FormData) => {
try {
if (!currentProject?.id && !isEdit) return;
if (isEdit) {
const updateData: TUpdateCertificateProfileDTO = {
profileId: profile.id,
slug: data.slug,
description: data.description
};
if (data.enrollmentType === "est" && data.estConfig) {
updateData.estConfig = data.estConfig;
} else if (data.enrollmentType === "api" && data.apiConfig) {
updateData.apiConfig = data.apiConfig;
}
await updateProfile.mutateAsync(updateData);
} else {
if (!currentProject?.id) {
throw new Error("Project ID is required for creating a profile");
}
const createData: TCreateCertificateProfileDTO = {
projectId: currentProject.id,
slug: data.slug,
description: data.description,
enrollmentType: data.enrollmentType,
caId: data.certificateAuthorityId,
certificateTemplateId: data.certificateTemplateId
};
if (data.enrollmentType === "est" && data.estConfig) {
createData.estConfig = {
passphrase: data.estConfig.passphrase,
caChain: data.estConfig.caChain || undefined,
disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation
};
} else if (data.enrollmentType === "api" && data.apiConfig) {
createData.apiConfig = data.apiConfig;
}
await createProfile.mutateAsync(createData);
}
createNotification({
text: `Certificate profile ${isEdit ? "updated" : "created"} successfully`,
type: "success"
});
reset();
onClose();
} catch (error) {
console.error(`Error ${isEdit ? "updating" : "creating"} profile:`, error);
createNotification({
text: `Failed to ${isEdit ? "update" : "create"} certificate profile`,
type: "error"
});
}
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) {
reset();
}
onClose();
}}
>
<ModalContent
title={isEdit ? "Edit Certificate Profile" : "Create Certificate Profile"}
subTitle={
isEdit
? `Update configuration for ${profile?.slug}`
: "Configure a new certificate profile for unified certificate issuance"
}
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="your-profile-name" />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl label="Description" isError={Boolean(error)} errorText={error?.message}>
<TextArea {...field} placeholder="Enter profile description" rows={3} />
</FormControl>
)}
/>
<Controller
control={control}
name="certificateAuthorityId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
onValueChange={onChange}
placeholder="Select a certificate authority"
className="w-full"
position="popper"
isDisabled={Boolean(isEdit)}
>
{certificateAuthorities.map((ca) => (
<SelectItem key={ca.id} value={ca.id}>
{ca.type === "internal" && ca.configuration.friendlyName
? ca.configuration.friendlyName
: ca.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="certificateTemplateId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Certificate Template"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
onValueChange={(value) => {
if (watchedEnrollmentType === "est") {
setValue("estConfig", {
disableBootstrapCaValidation: false,
passphrase: ""
});
setValue("apiConfig", undefined);
} else {
setValue("apiConfig", {
autoRenew: false,
autoRenewDays: 30
});
setValue("estConfig", undefined);
}
onChange(value);
}}
placeholder="Select a certificate template"
className="w-full"
position="popper"
isDisabled={Boolean(isEdit)}
>
{certificateTemplates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="enrollmentType"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Enrollment Type"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
onValueChange={(value) => {
if (value === "est") {
setValue("apiConfig", undefined);
setValue("estConfig", {
disableBootstrapCaValidation: false,
passphrase: ""
});
} else {
setValue("estConfig", undefined);
setValue("apiConfig", {
autoRenew: false,
autoRenewDays: 30
});
}
onChange(value);
}}
className="w-full"
position="popper"
isDisabled={Boolean(isEdit)}
>
<SelectItem value="api">API</SelectItem>
<SelectItem value="est">EST</SelectItem>
</Select>
</FormControl>
)}
/>
{/* EST Configuration */}
{watchedEnrollmentType === "est" && (
<div className="mb-4 space-y-4">
<div className="space-y-4">
<Controller
control={control}
name="estConfig.disableBootstrapCaValidation"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="flex items-center gap-3 rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4">
<Checkbox
id="disableBootstrapCaValidation"
isChecked={value}
onCheckedChange={onChange}
/>
<div className="space-y-1">
<span className="text-sm font-medium text-mineshaft-100">
Disable Bootstrap CA Validation
</span>
<p className="text-xs text-bunker-300">
Skip CA certificate validation during EST bootstrap phase
</p>
</div>
</div>
</FormControl>
)}
/>
<Controller
control={control}
name="estConfig.passphrase"
render={({ field, fieldState: { error } }) => (
<FormControl
label="EST Passphrase"
isRequired={!isEdit}
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="password"
placeholder="Enter secure passphrase for EST authentication"
className="w-full"
/>
</FormControl>
)}
/>
{!watchedDisableBootstrapValidation && (
<Controller
control={control}
name="estConfig.caChain"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA Chain Certificate"
isRequired={!isEdit}
isError={Boolean(error)}
errorText={error?.message}
>
<div className="space-y-2">
<TextArea
{...field}
placeholder="-----BEGIN CERTIFICATE-----&#10;MIIDXTCCAkWgAwIBAgIJAKoK/heBjcOuMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV&#10;BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX&#10;...&#10;-----END CERTIFICATE-----"
rows={6}
className="w-full font-mono text-xs"
/>
<p className="text-xs text-bunker-400">
Paste the complete CA certificate chain in PEM format
</p>
</div>
</FormControl>
)}
/>
)}
</div>
</div>
)}
{/* API Configuration */}
{watchedEnrollmentType === "api" && (
<div className="mb-4 space-y-4">
<Controller
control={control}
name="apiConfig.autoRenew"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Checkbox id="autoRenew" isChecked={value} onCheckedChange={onChange}>
Enable Auto-Renewal
</Checkbox>
</FormControl>
)}
/>
</div>
)}
{watchedAutoRenew && (
<div className="mb-4 space-y-4">
<Controller
control={control}
name="apiConfig.autoRenewDays"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Auto-Renewal Days"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="number"
placeholder="30"
min="1"
max="365"
className="w-full"
isDisabled={!watchedAutoRenew}
onChange={(e) => {
const { value } = e.target;
if (value === "") {
field.onChange("");
} else {
const parsed = parseInt(value, 10);
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 365) {
field.onChange(parsed);
} else {
field.onChange(field.value || "");
}
}
}}
/>
</FormControl>
)}
/>
</div>
)}
<div className="flex gap-3">
<Button
type="submit"
colorSchema="primary"
isLoading={isEdit ? updateProfile.isPending : createProfile.isPending}
isDisabled={
!formState.isValid || (isEdit ? updateProfile.isPending : createProfile.isPending)
}
>
{isEdit ? "Save Changes" : "Create"}
</Button>
<Button
variant="outline_bg"
onClick={onClose}
disabled={isEdit ? updateProfile.isPending : createProfile.isPending}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,101 @@
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useProject } from "@app/context";
import {
TCertificateProfileWithDetails,
useListCertificateProfiles
} from "@app/hooks/api/certificateProfiles";
import { ProfileRow } from "./ProfileRow";
interface Props {
onEditProfile: (profile: TCertificateProfileWithDetails) => void;
onDeleteProfile: (profile: TCertificateProfileWithDetails) => void;
}
export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
const { currentProject } = useProject();
const { data, isLoading } = useListCertificateProfiles({
projectId: currentProject?.id || "",
limit: 100,
offset: 0,
includeConfigs: true,
includeMetrics: true
});
const profiles = data?.certificateProfiles || [];
if (!currentProject?.id) {
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Enrollment Type</Th>
<Th>Issuing CA</Th>
<Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
<Tr>
<Td colSpan={6}>
<EmptyState title="No Project Selected" />
</Td>
</Tr>
</TBody>
</Table>
</TableContainer>
);
}
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Enrollment Type</Th>
<Th>Issuing CA</Th>
<Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={6} innerKey="certificate-profiles" />}
{!isLoading && (!profiles || profiles.length === 0) && (
<Tr>
<Td colSpan={6}>
<EmptyState title="No Certificate Profiles" />
</Td>
</Tr>
)}
{!isLoading &&
profiles &&
profiles.length > 0 &&
profiles.map((profile) => (
<ProfileRow
key={profile.id}
profile={profile}
onEditProfile={onEditProfile}
onDeleteProfile={onDeleteProfile}
/>
))}
</TBody>
</Table>
</TableContainer>
);
};
@@ -0,0 +1,236 @@
import { useCallback } from "react";
import {
faCheck,
faCircleInfo,
faCopy,
faEdit,
faEllipsis,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { useProjectPermission } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { usePopUp, useToggle } from "@app/hooks";
import { useGetCaById } from "@app/hooks/api/ca/queries";
import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal";
const MetricsBadges = ({
metrics
}: {
metrics?: {
totalCertificates: number;
activeCertificates: number;
expiringCertificates: number;
expiredCertificates: number;
revokedCertificates: number;
};
}) => {
if (!metrics) {
return (
<Badge variant="primary" className="text-xs">
No metrics
</Badge>
);
}
if (metrics.totalCertificates === 0) {
return (
<Badge variant="primary" className="text-xs">
No certificates
</Badge>
);
}
return (
<>
{metrics.activeCertificates > 0 && (
<Badge variant="success" className="text-xs">
{metrics.activeCertificates} active
</Badge>
)}
{metrics.expiringCertificates > 0 && (
<Badge variant="primary" className="text-xs">
{metrics.expiringCertificates} expiring
</Badge>
)}
{metrics.expiredCertificates > 0 && (
<Badge variant="danger" className="text-xs">
{metrics.expiredCertificates} expired
</Badge>
)}
{metrics.revokedCertificates > 0 && (
<Badge variant="danger" className="text-xs">
{metrics.revokedCertificates} revoked
</Badge>
)}
</>
);
};
interface Props {
profile: TCertificateProfile;
onEditProfile: (profile: TCertificateProfile) => void;
onDeleteProfile: (profile: TCertificateProfile) => void;
}
export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) => {
const { permission } = useProjectPermission();
const { data: caData } = useGetCaById(profile.caId);
const { popUp, handlePopUpToggle } = usePopUp(["certificateIssuance"] as const);
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(() => {
setIsIdCopied.on();
navigator.clipboard.writeText(profile.id);
createNotification({
text: "Profile ID copied to clipboard",
type: "info"
});
setTimeout(() => setIsIdCopied.off(), 2000);
}, [setIsIdCopied]);
const { data: templateData } = useGetCertificateTemplateV2ById({
templateId: profile.certificateTemplateId
});
const canEditProfile = permission.can(
ProjectPermissionActions.Edit,
ProjectPermissionSub.CertificateAuthorities
);
const canIssueCertificate = permission.can(
ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionSub.CertificateProfiles
);
const canDeleteProfile = permission.can(
ProjectPermissionActions.Delete,
ProjectPermissionSub.CertificateAuthorities
);
const getEnrollmentTypeBadge = (enrollmentType: string) => {
const config = {
api: { variant: "success" as const, label: "API" },
est: { variant: "primary" as const, label: "EST" }
} as const;
const configKey = Object.keys(config).includes(enrollmentType)
? (enrollmentType as keyof typeof config)
: "api";
const { variant, label } = config[configKey];
return <Badge variant={variant}>{label}</Badge>;
};
return (
<Tr key={profile.id} className="h-10 transition-colors duration-100 hover:bg-mineshaft-700">
<Td>
<div className="flex items-center gap-2">
<div className="text-mineshaft-300">{profile.slug}</div>
{profile.description && (
<Tooltip content={profile.description}>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</Tooltip>
)}
</div>
</Td>
<Td className="text-start">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td>
<Td className="text-start">
<span className="text-sm text-mineshaft-300">
{caData?.friendlyName || caData?.commonName || profile.caId}
</span>
</Td>
<Td>
<span className="text-sm text-mineshaft-300">
{templateData?.name || profile.certificateTemplateId}
</span>
</Td>
<Td>
<div className="flex flex-wrap gap-1">
<MetricsBadges metrics={profile.metrics} />
</div>
</Td>
<Td className="text-right">
<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">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
>
Copy Profile ID
</DropdownMenuItem>
{canEditProfile && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEditProfile(profile);
}}
icon={<FontAwesomeIcon icon={faEdit} />}
>
Edit Profile
</DropdownMenuItem>
)}
{canIssueCertificate && profile.enrollmentType === "api" && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpToggle("certificateIssuance");
}}
icon={<FontAwesomeIcon icon={faPlus} />}
>
Issue Certificate
</DropdownMenuItem>
)}
{canDeleteProfile && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteProfile(profile);
}}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Profile
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<CertificateIssuanceModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
profileId={profile.id}
/>
</Td>
</Tr>
);
};
@@ -0,0 +1,3 @@
export { CertificateProfilesTab } from "./CertificateProfilesTab";
export { CreateProfileModal } from "./CreateProfileModal";
export { ProfileList } from "./ProfileList";
@@ -0,0 +1,123 @@
import { useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
import { useProjectPermission } from "@app/context";
import {
ProjectPermissionPkiTemplateActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { useDeleteCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/mutations";
import { TCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/types";
import { CreateTemplateModal } from "./CreateTemplateModal";
import { TemplateList } from "./TemplateList";
export const CertificateTemplatesV2Tab = () => {
const { permission } = useProjectPermission();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] =
useState<TCertificateTemplateV2WithPolicies | null>(null);
const deleteTemplateV2 = useDeleteCertificateTemplateV2WithPolicies();
const canCreateTemplate = permission.can(
ProjectPermissionPkiTemplateActions.Create,
ProjectPermissionSub.CertificateTemplates
);
const handleCreateTemplate = () => {
setIsCreateModalOpen(true);
};
const handleEditTemplate = (template: TCertificateTemplateV2WithPolicies) => {
setSelectedTemplate(template);
setIsEditModalOpen(true);
};
const handleDeleteTemplate = (template: TCertificateTemplateV2WithPolicies) => {
setSelectedTemplate(template);
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (!selectedTemplate) return;
try {
await deleteTemplateV2.mutateAsync({
templateId: selectedTemplate.id
});
setIsDeleteModalOpen(false);
setSelectedTemplate(null);
createNotification({
text: `Certificate template "${selectedTemplate.name}" deleted successfully`,
type: "success"
});
} catch (error) {
console.error("Failed to delete template:", error);
createNotification({
text: "Failed to delete certificate template",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-mineshaft-100">Certificate Templates</h2>
<p className="text-sm text-bunker-300">
Define certificate policies, validation rules, and attribute constraints for certificate
issuance
</p>
</div>
{canCreateTemplate && (
<Button
colorSchema="primary"
type="button"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleCreateTemplate}
>
Create Template
</Button>
)}
</div>
<TemplateList onEditTemplate={handleEditTemplate} onDeleteTemplate={handleDeleteTemplate} />
<CreateTemplateModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
{selectedTemplate && (
<>
<CreateTemplateModal
isOpen={isEditModalOpen}
onClose={() => {
setIsEditModalOpen(false);
setSelectedTemplate(null);
}}
template={selectedTemplate}
mode="edit"
/>
<DeleteActionModal
isOpen={isDeleteModalOpen}
title={`Delete Certificate Template ${selectedTemplate.name}?`}
onChange={(isOpen) => {
setIsDeleteModalOpen(isOpen);
if (!isOpen) setSelectedTemplate(null);
}}
deleteKey={selectedTemplate.name}
onDeleteApproved={handleDeleteConfirm}
/>
</>
)}
</div>
);
};
@@ -0,0 +1,961 @@
/* eslint-disable no-nested-ternary */
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Checkbox,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useProject } from "@app/context";
import {
useCreateCertificateTemplateV2WithPolicies,
useUpdateCertificateTemplateV2WithPolicies
} from "@app/hooks/api/certificateTemplates/mutations";
import {
TCertificateTemplateV2Policy,
TCertificateTemplateV2WithPolicies
} from "@app/hooks/api/certificateTemplates/types";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType,
SAN_INCLUDE_OPTIONS,
SAN_TYPE_OPTIONS,
SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS,
SUBJECT_ATTRIBUTE_TYPE_OPTIONS
} from "./shared/certificate-constants";
import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared";
export type FormData = TemplateFormData;
type AttributeTransform = NonNullable<TCertificateTemplateV2Policy["subject"]>[0];
type SanTransform = NonNullable<TCertificateTemplateV2Policy["sans"]>[0];
type KeyUsagesTransform = TCertificateTemplateV2Policy["keyUsages"];
type ExtendedKeyUsagesTransform = TCertificateTemplateV2Policy["extendedKeyUsages"];
type AlgorithmsTransform = TCertificateTemplateV2Policy["algorithms"];
type ValidityTransform = TCertificateTemplateV2Policy["validity"];
interface Props {
isOpen: boolean;
onClose: () => void;
template?: TCertificateTemplateV2WithPolicies;
mode?: "create" | "edit";
}
const ATTRIBUTE_TYPE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_TYPE_OPTIONS)[number], string> = {
common_name: "Common Name (CN)"
};
const SAN_TYPE_LABELS: Record<(typeof SAN_TYPE_OPTIONS)[number], string> = {
dns_name: "DNS Name",
ip_address: "IP Address",
email: "Email",
uri: "URI"
};
const SUBJECT_ATTRIBUTE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS)[number], string> =
{
optional: "Allow",
prohibit: "Deny"
};
const SAN_INCLUDE_LABELS: Record<(typeof SAN_INCLUDE_OPTIONS)[number], string> = {
mandatory: "Require",
optional: "Allow",
prohibit: "Deny"
};
const SIGNATURE_ALGORITHMS = [
"SHA256-RSA",
"SHA384-RSA",
"SHA512-RSA",
"SHA256-ECDSA",
"SHA384-ECDSA",
"SHA512-ECDSA"
];
const KEY_ALGORITHMS = [
"RSA-2048",
"RSA-3072",
"RSA-4096",
"ECDSA-P256",
"ECDSA-P384",
"ECDSA-P521"
];
export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" }: Props) => {
const { currentProject } = useProject();
const createTemplate = useCreateCertificateTemplateV2WithPolicies();
const updateTemplate = useUpdateCertificateTemplateV2WithPolicies();
const isEdit = mode === "edit" && template;
const convertApiToUiFormat = (templateData: TCertificateTemplateV2WithPolicies): FormData => {
const attributes: FormData["attributes"] = [];
if (templateData.subject && Array.isArray(templateData.subject)) {
templateData.subject.forEach((subj) => {
if (subj.allowed && Array.isArray(subj.allowed)) {
subj.allowed.forEach((allowedValue) => {
attributes.push({
type: subj.type as CertSubjectAttributeType,
include: "optional",
value: [allowedValue]
});
});
}
if (subj.denied && Array.isArray(subj.denied)) {
subj.denied.forEach((deniedValue) => {
attributes.push({
type: subj.type as CertSubjectAttributeType,
include: "prohibit",
value: [deniedValue]
});
});
}
});
}
const subjectAlternativeNames: FormData["subjectAlternativeNames"] = [];
if (templateData.sans && Array.isArray(templateData.sans)) {
templateData.sans.forEach((san) => {
if (san.required && Array.isArray(san.required)) {
san.required.forEach((requiredValue) => {
subjectAlternativeNames.push({
type: san.type as CertSubjectAlternativeNameType,
include: "mandatory",
value: [requiredValue]
});
});
}
if (san.allowed && Array.isArray(san.allowed)) {
san.allowed.forEach((allowedValue) => {
subjectAlternativeNames.push({
type: san.type as CertSubjectAlternativeNameType,
include: "optional",
value: [allowedValue]
});
});
}
if (san.denied && Array.isArray(san.denied)) {
san.denied.forEach((deniedValue) => {
subjectAlternativeNames.push({
type: san.type as CertSubjectAlternativeNameType,
include: "prohibit",
value: [deniedValue]
});
});
}
});
}
const keyUsages = {
requiredUsages: (templateData.keyUsages?.required || []) as CertKeyUsageType[],
optionalUsages: (templateData.keyUsages?.allowed || []) as CertKeyUsageType[]
};
const extendedKeyUsages = {
requiredUsages: (templateData.extendedKeyUsages?.required ||
[]) as CertExtendedKeyUsageType[],
optionalUsages: (templateData.extendedKeyUsages?.allowed || []) as CertExtendedKeyUsageType[]
};
const validity = templateData.validity?.max
? (() => {
const maxValue = templateData.validity.max;
if (maxValue.length < 2) return undefined;
const lastChar = maxValue.slice(-1);
const numberPart = maxValue.slice(0, -1);
const value = parseInt(numberPart, 10);
if (Number.isNaN(value) || value <= 0 || numberPart !== value.toString()) {
return undefined;
}
let unit: CertDurationUnit = CertDurationUnit.DAYS;
if (lastChar === "d") {
unit = CertDurationUnit.DAYS;
} else if (lastChar === "m") {
unit = CertDurationUnit.MONTHS;
} else if (lastChar === "y") {
unit = CertDurationUnit.YEARS;
} else {
return undefined;
}
return {
maxDuration: { value, unit }
};
})()
: { maxDuration: { value: 365, unit: CertDurationUnit.DAYS } };
const signatureAlgorithm = {
allowedAlgorithms: templateData.algorithms?.signature || [],
defaultAlgorithm: ""
};
const keyAlgorithm = {
allowedKeyTypes: templateData.algorithms?.keyAlgorithm || [],
defaultKeyType: ""
};
return {
name: templateData.name || "",
description: templateData.description || "",
attributes,
subjectAlternativeNames,
keyUsages,
extendedKeyUsages,
validity,
signatureAlgorithm,
keyAlgorithm
};
};
const getDefaultValues = (): FormData => ({
name: "",
description: "",
attributes: [],
keyUsages: { requiredUsages: [], optionalUsages: [] },
extendedKeyUsages: { requiredUsages: [], optionalUsages: [] },
subjectAlternativeNames: [],
validity: {
maxDuration: { value: 365, unit: CertDurationUnit.DAYS }
},
signatureAlgorithm: {
allowedAlgorithms: []
},
keyAlgorithm: {
allowedKeyTypes: []
}
});
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(templateSchema),
defaultValues: getDefaultValues()
});
useEffect(() => {
if (isEdit && template) {
const convertedData = convertApiToUiFormat(template);
reset(convertedData);
} else if (!isEdit) {
reset(getDefaultValues());
}
}, [isEdit, template, reset]);
const watchedAttributes = watch("attributes") || [];
const watchedSans = watch("subjectAlternativeNames") || [];
const watchedKeyUsages = watch("keyUsages") || { requiredUsages: [], optionalUsages: [] };
const watchedExtendedKeyUsages = watch("extendedKeyUsages") || {
requiredUsages: [],
optionalUsages: []
};
const consolidateByType = <
T extends { type: string; allowed?: string[]; required?: string[]; denied?: string[] }
>(
items: T[]
): T[] => {
const consolidated = new Map<string, T>();
items.forEach((item) => {
const existing = consolidated.get(item.type);
if (existing) {
const mergedItem = {
...item,
allowed: [...new Set([...(existing.allowed || []), ...(item.allowed || [])])],
required: [...new Set([...(existing.required || []), ...(item.required || [])])],
denied: [...new Set([...(existing.denied || []), ...(item.denied || [])])]
} as T;
if (mergedItem.allowed?.length === 0) delete mergedItem.allowed;
if (mergedItem.required?.length === 0) delete mergedItem.required;
if (mergedItem.denied?.length === 0) delete mergedItem.denied;
consolidated.set(item.type, mergedItem);
} else {
consolidated.set(item.type, item);
}
});
return Array.from(consolidated.values());
};
const transformToApiFormat = (data: FormData) => {
const subjectRaw =
data.attributes?.map((attr) => {
const result: AttributeTransform = { type: attr.type };
if (attr.include === "optional" && attr.value && attr.value.length > 0) {
result.allowed = attr.value;
} else if (attr.include === "prohibit" && attr.value && attr.value.length > 0) {
result.denied = attr.value;
}
return result;
}) || [];
const sansRaw =
data.subjectAlternativeNames?.map((san) => {
const result: SanTransform = { type: san.type };
if (san.include === "mandatory" && san.value && san.value.length > 0) {
result.required = san.value;
} else if (san.include === "optional" && san.value && san.value.length > 0) {
result.allowed = san.value;
} else if (san.include === "prohibit" && san.value && san.value.length > 0) {
result.denied = san.value;
}
return result;
}) || [];
const subject = consolidateByType(subjectRaw);
const sans = consolidateByType(sansRaw);
const keyUsages: KeyUsagesTransform = {
required: [],
allowed: []
};
if (data.keyUsages?.requiredUsages && data.keyUsages.requiredUsages.length > 0) {
keyUsages.required = data.keyUsages.requiredUsages;
}
if (data.keyUsages?.optionalUsages && data.keyUsages.optionalUsages.length > 0) {
keyUsages.allowed = data.keyUsages.optionalUsages;
}
const extendedKeyUsages: ExtendedKeyUsagesTransform = {
required: [],
allowed: []
};
if (
data.extendedKeyUsages?.requiredUsages &&
data.extendedKeyUsages.requiredUsages.length > 0
) {
extendedKeyUsages.required = data.extendedKeyUsages.requiredUsages;
}
if (
data.extendedKeyUsages?.optionalUsages &&
data.extendedKeyUsages.optionalUsages.length > 0
) {
extendedKeyUsages.allowed = data.extendedKeyUsages.optionalUsages;
}
const algorithms: AlgorithmsTransform = {};
if (
data.signatureAlgorithm?.allowedAlgorithms &&
data.signatureAlgorithm.allowedAlgorithms.length > 0
) {
algorithms.signature = data.signatureAlgorithm.allowedAlgorithms as Array<
| "SHA256-RSA"
| "SHA384-RSA"
| "SHA512-RSA"
| "SHA256-ECDSA"
| "SHA384-ECDSA"
| "SHA512-ECDSA"
>;
}
if (data.keyAlgorithm?.allowedKeyTypes && data.keyAlgorithm.allowedKeyTypes.length > 0) {
algorithms.keyAlgorithm = data.keyAlgorithm.allowedKeyTypes as Array<
"RSA-2048" | "RSA-3072" | "RSA-4096" | "ECDSA-P256" | "ECDSA-P384"
>;
}
const validity: ValidityTransform = {};
if (data.validity?.maxDuration) {
let unit = "d";
if (data.validity.maxDuration.unit === CertDurationUnit.DAYS) {
unit = "d";
} else if (data.validity.maxDuration.unit === CertDurationUnit.MONTHS) {
unit = "m";
} else {
unit = "y";
}
validity.max = `${data.validity.maxDuration.value}${unit}`;
}
return {
name: data.name,
description: data.description,
subject,
sans,
keyUsages,
extendedKeyUsages,
algorithms,
validity
};
};
const onFormSubmit = async (data: FormData) => {
try {
if (!currentProject?.id && !isEdit) return;
const hasEmptyAttributeValues = data.attributes?.some(
(attr) => !attr.value || attr.value.length === 0 || attr.value.some((v) => !v.trim())
);
const hasEmptySanValues = data.subjectAlternativeNames?.some(
(san) => !san.value || san.value.length === 0 || san.value.some((v) => !v.trim())
);
if (hasEmptyAttributeValues || hasEmptySanValues) {
createNotification({
text: "All values must be non-empty. Use wildcards (*) if needed.",
type: "error"
});
return;
}
const transformedData = transformToApiFormat(data);
if (isEdit) {
const updateData = {
templateId: template.id,
...transformedData
};
await updateTemplate.mutateAsync(updateData);
} else {
if (!currentProject?.id) {
throw new Error("Project ID is required for creating a template");
}
const createData = {
projectId: currentProject.id,
...transformedData
};
await createTemplate.mutateAsync(createData);
}
createNotification({
text: `Certificate template ${isEdit ? "updated" : "created"} successfully`,
type: "success"
});
reset();
onClose();
} catch (error) {
console.error(`Error ${isEdit ? "updating" : "creating"} template:`, error);
createNotification({
text: `Failed to ${isEdit ? "update" : "create"} certificate template`,
type: "error"
});
}
};
const addAttribute = () => {
const newAttribute = {
type: SUBJECT_ATTRIBUTE_TYPE_OPTIONS[0],
include: SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS[0],
value: ["*"]
};
setValue("attributes", [...watchedAttributes, newAttribute]);
};
const removeAttribute = (index: number) => {
const newAttributes = watchedAttributes.filter((_, i) => i !== index);
setValue("attributes", newAttributes);
};
const addSan = () => {
const newSan = {
type: SAN_TYPE_OPTIONS[0],
include: SAN_INCLUDE_OPTIONS[1],
value: ["*"]
};
setValue("subjectAlternativeNames", [...watchedSans, newSan]);
};
const removeSan = (index: number) => {
const newSans = watchedSans.filter((_, i) => i !== index);
setValue("subjectAlternativeNames", newSans);
};
const handleKeyUsagesChange = (usages: {
requiredUsages: CertKeyUsageType[];
optionalUsages: CertKeyUsageType[];
}) => {
setValue("keyUsages", {
requiredUsages: usages.requiredUsages,
optionalUsages: usages.optionalUsages
});
};
const handleExtendedKeyUsagesChange = (usages: {
requiredUsages: CertExtendedKeyUsageType[];
optionalUsages: CertExtendedKeyUsageType[];
}) => {
setValue("extendedKeyUsages", {
requiredUsages: usages.requiredUsages,
optionalUsages: usages.optionalUsages
});
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) {
reset();
}
onClose();
}}
>
<ModalContent
className="max-w-4xl"
title={isEdit ? "Edit Certificate Template" : "Create Certificate Template"}
subTitle={
isEdit
? `Update configuration for ${template?.name}`
: "Define comprehensive certificate policies, validation rules, and constraints"
}
>
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
<Accordion type="multiple" defaultValue={["basic", "algorithms"]} className="w-full">
<div className="space-y-4">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Template Name"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Enter template name" className="w-full" />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description"
isError={Boolean(error)}
errorText={error?.message}
>
<TextArea {...field} placeholder="Enter template description" rows={3} />
</FormControl>
)}
/>
</div>
<AccordionItem value="attributes">
<AccordionTrigger>Subject Attributes</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button
type="button"
onClick={addAttribute}
size="sm"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Attribute
</Button>
</div>
<div className="space-y-2">
{watchedAttributes.length === 0 ? (
<div className="py-8 text-center text-bunker-300">
No subject attributes configured yet. Click &quot;Add Attribute&quot; to get
started.
</div>
) : (
watchedAttributes.map((attr, index) => {
return (
<div
// eslint-disable-next-line react/no-array-index-key
key={`attr-${attr.type}-${attr.include}-${index}`}
className="flex items-start gap-2"
>
<Select
value={attr.type}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
type: value as CertSubjectAttributeType
};
setValue("attributes", newAttributes);
}}
className="w-48"
>
{SUBJECT_ATTRIBUTE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{ATTRIBUTE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={attr.include}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
include:
value as (typeof SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS)[number]
};
setValue("attributes", newAttributes);
}}
className="w-32"
>
{SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{SUBJECT_ATTRIBUTE_LABELS[type]}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (required - use * for wildcards)"
value={attr.value?.[0] || ""}
onChange={(e) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
value: e.target.value.trim() ? [e.target.value.trim()] : []
};
setValue("attributes", newAttributes);
}}
className={`flex-1 ${
attr.value && attr.value.length > 0 && attr.value[0] === ""
? "border-red-500 focus:border-red-500"
: ""
}`}
required
/>
{watchedAttributes.length > 0 && (
<IconButton
ariaLabel="Remove Attribute"
variant="plain"
size="sm"
onClick={() => removeAttribute(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</div>
);
})
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="san" className="mt-4">
<AccordionTrigger>Subject Alternative Names</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button
type="button"
onClick={addSan}
size="sm"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add SAN
</Button>
</div>
<div className="space-y-2">
{watchedSans.length === 0 ? (
<div className="py-8 text-center text-bunker-300">
No subject alternative names configured yet. Click &quot;Add SAN&quot; to
get started.
</div>
) : (
watchedSans.map((san, index) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`san-${san.type}-${san.include}-${index}`}
className="flex items-start gap-2"
>
<Select
value={san.type}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
type: value as CertSubjectAlternativeNameType
};
setValue("subjectAlternativeNames", newSans);
}}
className="w-36"
>
{SAN_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{SAN_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={san.include}
onValueChange={(value) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
include: value as (typeof SAN_INCLUDE_OPTIONS)[number]
};
setValue("subjectAlternativeNames", newSans);
}}
className="w-32"
>
{SAN_INCLUDE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{SAN_INCLUDE_LABELS[type]}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (required - use * for wildcards)"
value={san.value?.[0] || ""}
onChange={(e) => {
const newSans = [...watchedSans];
newSans[index] = {
...san,
value: e.target.value.trim() ? [e.target.value.trim()] : []
};
setValue("subjectAlternativeNames", newSans);
}}
className={`flex-1 ${
san.value && san.value.length > 0 && san.value[0] === ""
? "border-red-500 focus:border-red-500"
: ""
}`}
required
/>
{watchedSans.length > 0 && (
<IconButton
ariaLabel="Remove SAN"
variant="plain"
size="sm"
onClick={() => removeSan(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</div>
))
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="algorithms" className="mt-4">
<AccordionTrigger>Algorithms</AccordionTrigger>
<AccordionContent>
<div className="space-y-6">
<div>
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">
Allowed Signature Algorithms
<span className="ml-1 text-red-500">*</span>
</h4>
<Controller
control={control}
name="signatureAlgorithm.allowedAlgorithms"
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="grid grid-cols-2 gap-2">
{SIGNATURE_ALGORITHMS.map((alg) => {
const isSelected = field.value?.includes(alg);
return (
<div key={alg} className="flex items-center space-x-3">
<Checkbox
id={`sig-alg-${alg}`}
isChecked={isSelected}
onCheckedChange={(checked) => {
const current = field.value || [];
let newValue;
if (checked && !isSelected) {
newValue = [...current, alg];
} else if (!checked && isSelected) {
newValue = current.filter((a) => a !== alg);
} else {
return;
}
field.onChange(newValue);
}}
/>
<label
htmlFor={`sig-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200"
>
{alg}
</label>
</div>
);
})}
</div>
</FormControl>
)}
/>
</div>
<div>
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">
Allowed Key Algorithms
<span className="ml-1 text-red-500">*</span>
</h4>
<Controller
control={control}
name="keyAlgorithm.allowedKeyTypes"
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="grid grid-cols-2 gap-2">
{KEY_ALGORITHMS.map((alg) => {
const isSelected = field.value?.includes(alg);
return (
<div key={alg} className="flex items-center space-x-3">
<Checkbox
id={`key-alg-${alg}`}
isChecked={isSelected}
onCheckedChange={(checked) => {
const current = field.value || [];
let newValue;
if (checked && !isSelected) {
newValue = [...current, alg];
} else if (!checked && isSelected) {
newValue = current.filter((a) => a !== alg);
} else {
return;
}
field.onChange(newValue);
}}
/>
<label
htmlFor={`key-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200"
>
{alg}
</label>
</div>
);
})}
</div>
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="usages" className="mt-4">
<AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent>
<KeyUsagesSection
watchedKeyUsages={watchedKeyUsages}
watchedExtendedKeyUsages={watchedExtendedKeyUsages}
onKeyUsagesChange={handleKeyUsagesChange}
onExtendedKeyUsagesChange={handleExtendedKeyUsagesChange}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="validity" className="mt-4">
<AccordionTrigger>Certificate Validity</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="validity.maxDuration.value"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Max Duration"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="number"
placeholder="365"
className="w-full"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="validity.maxDuration.unit"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Unit"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
onValueChange={field.onChange}
className="w-full"
position="popper"
>
<SelectItem value="days">Days</SelectItem>
<SelectItem value="months">Months</SelectItem>
<SelectItem value="years">Years</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex gap-3">
<Button
type="submit"
colorSchema="primary"
isLoading={isEdit ? updateTemplate.isPending : createTemplate.isPending}
isDisabled={
!formState.isValid || (isEdit ? updateTemplate.isPending : createTemplate.isPending)
}
>
{isEdit ? "Save Changes" : "Create"}
</Button>
<Button
variant="outline_bg"
onClick={onClose}
disabled={isEdit ? updateTemplate.isPending : createTemplate.isPending}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,144 @@
import { faCircleInfo, faEdit, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useProject, useProjectPermission } from "@app/context";
import {
ProjectPermissionPkiTemplateActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
import { TCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/types";
interface Props {
onEditTemplate: (template: TCertificateTemplateV2WithPolicies) => void;
onDeleteTemplate: (template: TCertificateTemplateV2WithPolicies) => void;
}
export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
const { permission } = useProjectPermission();
const { currentProject } = useProject();
const { data, isLoading } = useListCertificateTemplatesV2({
projectId: currentProject?.id || "",
limit: 100,
offset: 0
});
const templates = data?.certificateTemplates || [];
if (!currentProject?.id) {
return null;
}
const canEditTemplate = permission.can(
ProjectPermissionPkiTemplateActions.Edit,
ProjectPermissionSub.CertificateTemplates
);
const canDeleteTemplate = permission.can(
ProjectPermissionPkiTemplateActions.Delete,
ProjectPermissionSub.CertificateTemplates
);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString();
};
const hasTemplates = !isLoading && templates && templates.length > 0;
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Created</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="certificate-templates" />}
{!isLoading && (!templates || templates.length === 0) && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No Certificate Templates" />
</Td>
</Tr>
)}
{hasTemplates &&
templates.map((template) => (
<Tr
key={template.id}
className="h-10 transition-colors duration-100 hover:bg-mineshaft-700"
>
<Td>
<div className="flex items-center gap-2">
<div className="text-mineshaft-300">{template.name}</div>
{template.description && (
<Tooltip content={template.description}>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</Tooltip>
)}
</div>
</Td>
<Td>
<span className="text-sm text-bunker-300">{formatDate(template.createdAt)}</span>
</Td>
<Td className="text-right">
<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">
{canEditTemplate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEditTemplate(template);
}}
icon={<FontAwesomeIcon icon={faEdit} />}
>
Edit Template
</DropdownMenuItem>
)}
{canDeleteTemplate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteTemplate(template);
}}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Template
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
);
};
@@ -0,0 +1,3 @@
export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
export { CreateTemplateModal } from "./CreateTemplateModal";
export { TemplateList } from "./TemplateList";
@@ -0,0 +1,241 @@
export enum CertSubjectAlternativeNameType {
DNS_NAME = "dns_name",
IP_ADDRESS = "ip_address",
EMAIL = "email",
URI = "uri"
}
export enum CertKeyUsageType {
DIGITAL_SIGNATURE = "digital_signature",
KEY_ENCIPHERMENT = "key_encipherment",
NON_REPUDIATION = "non_repudiation",
DATA_ENCIPHERMENT = "data_encipherment",
KEY_AGREEMENT = "key_agreement",
KEY_CERT_SIGN = "key_cert_sign",
CRL_SIGN = "crl_sign",
ENCIPHER_ONLY = "encipher_only",
DECIPHER_ONLY = "decipher_only"
}
export enum CertExtendedKeyUsageType {
CLIENT_AUTH = "client_auth",
SERVER_AUTH = "server_auth",
CODE_SIGNING = "code_signing",
EMAIL_PROTECTION = "email_protection",
OCSP_SIGNING = "ocsp_signing",
TIME_STAMPING = "time_stamping"
}
export enum CertAttributeRule {
ALLOW = "allow",
DENY = "deny",
REQUIRE = "require"
}
export enum CertSanEffect {
ALLOW = "allow",
DENY = "deny",
REQUIRE = "require"
}
export enum CertDurationUnit {
DAYS = "days",
MONTHS = "months",
YEARS = "years"
}
export enum CertSubjectAttributeType {
COMMON_NAME = "common_name"
}
export const formatSANType = (type: CertSubjectAlternativeNameType): string => {
switch (type) {
case CertSubjectAlternativeNameType.DNS_NAME:
return "DNS Name";
case CertSubjectAlternativeNameType.IP_ADDRESS:
return "IP Address";
case CertSubjectAlternativeNameType.EMAIL:
return "Email";
case CertSubjectAlternativeNameType.URI:
return "URI";
default:
return type;
}
};
export const formatKeyUsage = (usage: CertKeyUsageType): string => {
switch (usage) {
case CertKeyUsageType.DIGITAL_SIGNATURE:
return "Digital Signature";
case CertKeyUsageType.KEY_ENCIPHERMENT:
return "Key Encipherment";
case CertKeyUsageType.NON_REPUDIATION:
return "Non Repudiation";
case CertKeyUsageType.DATA_ENCIPHERMENT:
return "Data Encipherment";
case CertKeyUsageType.KEY_AGREEMENT:
return "Key Agreement";
case CertKeyUsageType.KEY_CERT_SIGN:
return "Key Cert Sign";
case CertKeyUsageType.CRL_SIGN:
return "CRL Sign";
case CertKeyUsageType.ENCIPHER_ONLY:
return "Encipher Only";
case CertKeyUsageType.DECIPHER_ONLY:
return "Decipher Only";
default:
return usage;
}
};
export const formatExtendedKeyUsage = (usage: CertExtendedKeyUsageType): string => {
switch (usage) {
case CertExtendedKeyUsageType.CLIENT_AUTH:
return "Client Auth";
case CertExtendedKeyUsageType.SERVER_AUTH:
return "Server Auth";
case CertExtendedKeyUsageType.CODE_SIGNING:
return "Code Signing";
case CertExtendedKeyUsageType.EMAIL_PROTECTION:
return "Email Protection";
case CertExtendedKeyUsageType.OCSP_SIGNING:
return "OCSP Signing";
case CertExtendedKeyUsageType.TIME_STAMPING:
return "Time Stamping";
default:
return usage;
}
};
export const formatSubjectAttributeType = (type: CertSubjectAttributeType): string => {
switch (type) {
case CertSubjectAttributeType.COMMON_NAME:
return "Common Name (CN)";
default:
return type;
}
};
export const formatAttributeRule = (rule: CertAttributeRule): string => {
switch (rule) {
case CertAttributeRule.ALLOW:
return "Allow";
case CertAttributeRule.DENY:
return "Deny";
case CertAttributeRule.REQUIRE:
return "Require";
default:
return rule;
}
};
export const formatSanEffect = (effect: CertSanEffect): string => {
switch (effect) {
case CertSanEffect.ALLOW:
return "Allow";
case CertSanEffect.DENY:
return "Deny";
case CertSanEffect.REQUIRE:
return "Require";
default:
return effect;
}
};
export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType);
export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType);
export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType);
export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit);
export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType);
export const ATTRIBUTE_RULE_OPTIONS = Object.values(CertAttributeRule);
export const SAN_EFFECT_OPTIONS = Object.values(CertSanEffect);
export const SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS = ["optional", "prohibit"] as const;
export const SAN_INCLUDE_OPTIONS = ["mandatory", "optional", "prohibit"] as const;
export const USAGE_STATES = {
REQUIRED: "required",
OPTIONAL: "optional"
} as const;
export type UsageState = (typeof USAGE_STATES)[keyof typeof USAGE_STATES] | undefined;
export enum CertKeyAlgorithm {
RSA_2048 = "RSA_2048",
RSA_3072 = "RSA_3072",
RSA_4096 = "RSA_4096",
ECDSA_P256 = "EC_prime256v1",
ECDSA_P384 = "EC_secp384r1"
}
export enum CertSignatureAlgorithm {
RSA_SHA256 = "RSA-SHA256",
RSA_SHA384 = "RSA-SHA384",
RSA_SHA512 = "RSA-SHA512",
ECDSA_SHA256 = "ECDSA-SHA256",
ECDSA_SHA384 = "ECDSA-SHA384",
ECDSA_SHA512 = "ECDSA-SHA512"
}
export const SIGNATURE_ALGORITHM_OPTIONS = Object.values(CertSignatureAlgorithm);
export const KEY_ALGORITHM_OPTIONS = Object.values(CertKeyAlgorithm);
// Display name mappings for UI
export const getSignatureAlgorithmDisplayName = (algorithm: CertSignatureAlgorithm): string => {
switch (algorithm) {
case CertSignatureAlgorithm.RSA_SHA256:
return "RSA with SHA-256";
case CertSignatureAlgorithm.RSA_SHA384:
return "RSA with SHA-384";
case CertSignatureAlgorithm.RSA_SHA512:
return "RSA with SHA-512";
case CertSignatureAlgorithm.ECDSA_SHA256:
return "ECDSA with SHA-256";
case CertSignatureAlgorithm.ECDSA_SHA384:
return "ECDSA with SHA-384";
case CertSignatureAlgorithm.ECDSA_SHA512:
return "ECDSA with SHA-512";
default:
return algorithm;
}
};
export const getKeyAlgorithmDisplayName = (algorithm: CertKeyAlgorithm): string => {
switch (algorithm) {
case CertKeyAlgorithm.RSA_2048:
return "RSA 2048";
case CertKeyAlgorithm.RSA_3072:
return "RSA 3072";
case CertKeyAlgorithm.RSA_4096:
return "RSA 4096";
case CertKeyAlgorithm.ECDSA_P256:
return "ECDSA P-256";
case CertKeyAlgorithm.ECDSA_P384:
return "ECDSA P-384";
default:
return algorithm;
}
};
export const mapTemplateSignatureAlgorithmToApi = (templateFormat: string): string => {
const mapping: Record<string, string> = {
"SHA256-RSA": "RSA-SHA256",
"SHA384-RSA": "RSA-SHA384",
"SHA512-RSA": "RSA-SHA512",
"SHA256-ECDSA": "ECDSA-SHA256",
"SHA384-ECDSA": "ECDSA-SHA384",
"SHA512-ECDSA": "ECDSA-SHA512"
};
return mapping[templateFormat] || templateFormat;
};
export const mapTemplateKeyAlgorithmToApi = (templateFormat: string): string => {
const mapping: Record<string, string> = {
"RSA-2048": "RSA_2048",
"RSA-3072": "RSA_3072",
"RSA-4096": "RSA_4096",
"ECDSA-P256": "EC_prime256v1",
"ECDSA-P384": "EC_secp384r1"
};
return mapping[templateFormat] || templateFormat;
};
@@ -0,0 +1,155 @@
import React from "react";
import { Select, SelectItem } from "@app/components/v2";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
EXTENDED_KEY_USAGE_OPTIONS,
formatExtendedKeyUsage,
formatKeyUsage,
KEY_USAGE_OPTIONS
} from "./certificate-constants";
type KeyUsagePolicy = "allow" | "require" | "deny";
interface KeyUsagesSectionProps {
watchedKeyUsages: {
requiredUsages: CertKeyUsageType[];
optionalUsages: CertKeyUsageType[];
};
watchedExtendedKeyUsages: {
requiredUsages: CertExtendedKeyUsageType[];
optionalUsages: CertExtendedKeyUsageType[];
};
onKeyUsagesChange: (usages: {
requiredUsages: CertKeyUsageType[];
optionalUsages: CertKeyUsageType[];
}) => void;
onExtendedKeyUsagesChange: (usages: {
requiredUsages: CertExtendedKeyUsageType[];
optionalUsages: CertExtendedKeyUsageType[];
}) => void;
}
export const KeyUsagesSection: React.FC<KeyUsagesSectionProps> = ({
watchedKeyUsages,
watchedExtendedKeyUsages,
onKeyUsagesChange,
onExtendedKeyUsagesChange
}) => {
const getKeyUsagePolicy = (usage: CertKeyUsageType): KeyUsagePolicy => {
if (watchedKeyUsages.requiredUsages.includes(usage)) return "require";
if (watchedKeyUsages.optionalUsages.includes(usage)) return "allow";
return "deny";
};
const getExtendedKeyUsagePolicy = (usage: CertExtendedKeyUsageType): KeyUsagePolicy => {
if (watchedExtendedKeyUsages.requiredUsages.includes(usage)) return "require";
if (watchedExtendedKeyUsages.optionalUsages.includes(usage)) return "allow";
return "deny";
};
const handleKeyUsagePolicyChange = (usage: CertKeyUsageType, policy: KeyUsagePolicy) => {
const newRequired = watchedKeyUsages.requiredUsages.filter((u) => u !== usage);
const newOptional = watchedKeyUsages.optionalUsages.filter((u) => u !== usage);
if (policy === "require") {
newRequired.push(usage);
} else if (policy === "allow") {
newOptional.push(usage);
}
onKeyUsagesChange({
requiredUsages: newRequired,
optionalUsages: newOptional
});
};
const handleExtendedKeyUsagePolicyChange = (
usage: CertExtendedKeyUsageType,
policy: KeyUsagePolicy
) => {
const newRequired = watchedExtendedKeyUsages.requiredUsages.filter((u) => u !== usage);
const newOptional = watchedExtendedKeyUsages.optionalUsages.filter((u) => u !== usage);
if (policy === "require") {
newRequired.push(usage);
} else if (policy === "allow") {
newOptional.push(usage);
}
onExtendedKeyUsagesChange({
requiredUsages: newRequired,
optionalUsages: newOptional
});
};
const policyOptions = [
{ value: "deny", label: "Deny" },
{ value: "allow", label: "Allow" },
{ value: "require", label: "Require" }
];
return (
<div className="space-y-8">
{/* Key Usages */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-bunker-200">Key Usages</h4>
<div className="grid grid-cols-2 gap-4">
{KEY_USAGE_OPTIONS.map((usage) => {
const policy = getKeyUsagePolicy(usage);
return (
<div key={usage} className="flex items-center justify-between">
<span className="text-sm text-bunker-200">{formatKeyUsage(usage)}</span>
<Select
value={policy}
onValueChange={(value) =>
handleKeyUsagePolicyChange(usage, value as KeyUsagePolicy)
}
className="w-32"
>
{policyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</Select>
</div>
);
})}
</div>
</div>
{/* Extended Key Usages */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-bunker-200">Extended Key Usages</h4>
<div className="grid grid-cols-2 gap-4">
{EXTENDED_KEY_USAGE_OPTIONS.map((usage) => {
const policy = getExtendedKeyUsagePolicy(usage);
return (
<div key={usage} className="flex items-center justify-between">
<span className="text-sm text-bunker-200">{formatExtendedKeyUsage(usage)}</span>
<Select
value={policy}
onValueChange={(value) =>
handleExtendedKeyUsagePolicyChange(usage, value as KeyUsagePolicy)
}
className="w-32"
>
{policyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</Select>
</div>
);
})}
</div>
</div>
</div>
);
};
@@ -0,0 +1,3 @@
export * from "./components";
export * from "./schemas";
export * from "./utils";
@@ -0,0 +1,150 @@
import { z } from "zod";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType,
SAN_INCLUDE_OPTIONS,
SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS
} from "./certificate-constants";
export const uiAttributeSchema = z.object({
type: z.nativeEnum(CertSubjectAttributeType),
include: z.enum(SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS),
value: z.array(z.string().min(1, "Value cannot be empty"))
});
export const uiSanSchema = z.object({
type: z.nativeEnum(CertSubjectAlternativeNameType),
include: z.enum(SAN_INCLUDE_OPTIONS),
value: z.array(z.string().min(1, "Value cannot be empty"))
});
export const uiKeyUsagesSchema = z.object({
requiredUsages: z.array(z.nativeEnum(CertKeyUsageType)),
optionalUsages: z.array(z.nativeEnum(CertKeyUsageType))
});
export const uiExtendedKeyUsagesSchema = z.object({
requiredUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)),
optionalUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType))
});
export const uiValiditySchema = z.object({
maxDuration: z.object({
value: z.number().min(1, "Duration must be at least 1"),
unit: z.nativeEnum(CertDurationUnit)
})
});
export const uiSignatureAlgorithmSchema = z.object({
allowedAlgorithms: z
.array(z.string().min(1, "Algorithm cannot be empty"))
.min(1, "At least one algorithm must be selected")
});
export const uiKeyAlgorithmSchema = z.object({
allowedKeyTypes: z
.array(z.string().min(1, "Key type cannot be empty"))
.min(1, "At least one key type must be selected")
});
export const templateSchema = z.object({
name: z
.string()
.trim()
.min(1, "Template name is required")
.max(255, "Template name must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Template name must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
attributes: z.array(uiAttributeSchema).optional(),
subjectAlternativeNames: z.array(uiSanSchema).optional(),
keyUsages: uiKeyUsagesSchema.optional(),
extendedKeyUsages: uiExtendedKeyUsagesSchema.optional(),
validity: uiValiditySchema.optional(),
signatureAlgorithm: uiSignatureAlgorithmSchema,
keyAlgorithm: uiKeyAlgorithmSchema
});
export type TemplateFormData = z.infer<typeof templateSchema>;
export const apiSubjectSchema = z
.object({
type: z.nativeEnum(CertSubjectAttributeType),
allowed: z.array(z.string().min(1, "Value cannot be empty")).optional(),
required: z.array(z.string().min(1, "Value cannot be empty")).optional(),
denied: z.array(z.string().min(1, "Value cannot be empty")).optional()
})
.refine((data) => data.allowed || data.required || data.denied, {
message: "At least one allowed, required, or denied value must be provided"
});
export const apiSanSchema = z
.object({
type: z.nativeEnum(CertSubjectAlternativeNameType),
allowed: z.array(z.string().min(1, "Value cannot be empty")).optional(),
required: z.array(z.string().min(1, "Value cannot be empty")).optional(),
denied: z.array(z.string().min(1, "Value cannot be empty")).optional()
})
.refine((data) => data.allowed || data.required || data.denied, {
message: "At least one allowed, required, or denied value must be provided"
});
export const apiTemplateSchema = z.object({
name: z
.string()
.trim()
.min(1, "Template name is required")
.max(255, "Template name must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Template name must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
subject: z.array(apiSubjectSchema).optional(),
sans: z.array(apiSanSchema).optional(),
keyUsages: z
.object({
allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
required: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertKeyUsageType)).optional()
})
.optional(),
extendedKeyUsages: z
.object({
allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional()
})
.optional(),
algorithms: z
.object({
signature: z.array(z.string().trim().min(1, "Algorithm cannot be empty")).optional(),
keyAlgorithm: z.array(z.string().trim().min(1, "Algorithm cannot be empty")).optional()
})
.optional(),
validity: z
.object({
max: z
.string()
.trim()
.regex(/^[1-9]\d*[dhmy]$/, "Must be in format like '365d', '12m', '1y', or '24h'")
.optional()
})
.optional()
});
export type ApiTemplateFormData = z.infer<typeof apiTemplateSchema>;
@@ -0,0 +1,51 @@
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
formatExtendedKeyUsage,
formatKeyUsage,
USAGE_STATES,
UsageState
} from "./certificate-constants";
export const formatUsageName = (usage: string): string => {
if (Object.values(CertKeyUsageType).includes(usage as CertKeyUsageType)) {
return formatKeyUsage(usage as CertKeyUsageType);
}
if (Object.values(CertExtendedKeyUsageType).includes(usage as CertExtendedKeyUsageType)) {
return formatExtendedKeyUsage(usage as CertExtendedKeyUsageType);
}
return usage.replace(/_/g, " ");
};
export const getUsageState = (
usage: CertKeyUsageType | CertExtendedKeyUsageType,
requiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
optionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[]
): UsageState => {
if (requiredUsages.includes(usage)) return USAGE_STATES.REQUIRED;
if (optionalUsages.includes(usage)) return USAGE_STATES.OPTIONAL;
return undefined;
};
export const toggleUsageState = (
usage: CertKeyUsageType | CertExtendedKeyUsageType,
newState: UsageState,
currentRequiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
currentOptionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
toggleRequired: (usage: CertKeyUsageType | CertExtendedKeyUsageType) => void,
toggleOptional: (usage: CertKeyUsageType | CertExtendedKeyUsageType) => void
) => {
const isRequired = currentRequiredUsages.includes(usage);
const isOptional = currentOptionalUsages.includes(usage);
if (newState === USAGE_STATES.REQUIRED) {
if (isOptional) toggleOptional(usage);
if (!isRequired) toggleRequired(usage);
} else if (newState === USAGE_STATES.OPTIONAL) {
if (isRequired) toggleRequired(usage);
if (!isOptional) toggleOptional(usage);
} else {
if (isRequired) toggleRequired(usage);
if (isOptional) toggleOptional(usage);
}
};
@@ -0,0 +1,2 @@
export { CertificateProfilesTab } from "./CertificateProfilesTab";
export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
@@ -0,0 +1 @@
export { PoliciesPage } from "./PoliciesPage";
@@ -0,0 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
import { PoliciesPage } from "./PoliciesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies"
)({
component: PoliciesPage,
beforeLoad: ({ context }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Certificate Policies"
}
]
};
}
});
@@ -2,6 +2,7 @@ import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectType } from "@app/hooks/api/projects/types";
import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/ProjectGeneralTab";
const tabs = [
@@ -20,12 +21,12 @@ export const SettingsPage = () => {
<Helmet>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
</Helmet>
<div className="w-full max-w-7xl">
<PageHeader scope="project" title={t("settings.project.title")} />
<Tabs defaultValue={tabs[0].key}>
<div className="w-full max-w-8xl">
<PageHeader scope={ProjectType.CertificateManager} title={t("settings.project.title")} />
<Tabs orientation="vertical" defaultValue={tabs[0].key}>
<TabList>
{tabs.map((tab) => (
<Tab value={tab.key} key={tab.key}>
<Tab value={tab.key} variant="project" key={tab.key}>
{tab.name}
</Tab>
))}
+4 -3
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionKmipActions, ProjectPermissionSub } from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { KmipClientTable } from "./components/KmipClientTable";
@@ -15,10 +16,10 @@ export const KmipPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "KMS" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.KMS}
title="KMIP"
description="Integrate with Infisical KMS via Key Management Interoperability Protocol."
/>
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { CmekTable } from "./components";
@@ -15,10 +16,10 @@ export const OverviewPage = () => {
<Helmet>
<title>{t("common.head-title", { title: "KMS" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={ProjectType.KMS}
title="Overview"
description="Manage keys and perform cryptographic operations."
/>
@@ -2,6 +2,7 @@ import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectType } from "@app/hooks/api/projects/types";
import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/ProjectGeneralTab";
const tabs = [
@@ -20,12 +21,16 @@ export const SettingsPage = () => {
<Helmet>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
</Helmet>
<div className="w-full max-w-7xl">
<PageHeader scope="project" title="Settings" />
<Tabs defaultValue={tabs[0].key}>
<div className="w-full max-w-8xl">
<PageHeader
scope={ProjectType.KMS}
title="Settings"
description="Configure general project settings"
/>
<Tabs orientation="vertical" defaultValue={tabs[0].key}>
<TabList>
{tabs.map((tab) => (
<Tab value={tab.key} key={tab.key}>
<Tab variant="project" value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
@@ -24,7 +24,7 @@ import { OrgGroupsTab, OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "
export const AccessManagementPage = () => {
const { t } = useTranslation();
const { permission } = useOrgPermission();
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const navigate = useNavigate({
from: ROUTE_PATHS.Organization.AccessControlPage.path
@@ -76,13 +76,13 @@ export const AccessManagementPage = () => {
const hasNoAccess = tabSections.every((tab) => tab.isHidden);
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="org"
scope={isSubOrganization ? "namespace" : "org"}
title="Access Control"
description="Manage fine-grained access for users, groups, roles, and identities within your organization resources."
/>
@@ -111,12 +111,16 @@ export const AccessManagementPage = () => {
isOpen={isUpgradePrivilegeSystemModalOpen}
onOpenChange={setIsUpgradePrivilegeSystemModalOpen}
/>
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
<Tabs orientation="vertical" value={selectedTab} onValueChange={updateSelectedTab}>
<TabList>
{tabSections
.filter((el) => !el.isHidden)
.map((el) => (
<Tab variant="org" value={el.key} key={`org-access-tab-${el.key}`}>
<Tab
variant={isSubOrganization ? "namespace" : "org"}
value={el.key}
key={`org-access-tab-${el.key}`}
>
{el.label}
</Tab>
))}
@@ -1,17 +1,5 @@
import { motion } from "framer-motion";
import { OrgGroupsSection } from "./components";
export const OrgGroupsTab = () => {
return (
<motion.div
key="panel-org-groups"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<OrgGroupsSection />
</motion.div>
);
return <OrgGroupsSection />;
};
@@ -1,17 +1,5 @@
import { motion } from "framer-motion";
import { IdentitySection } from "./components";
export const OrgIdentityTab = () => {
return (
<motion.div
key="panel-service-token"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<IdentitySection />
</motion.div>
);
return <IdentitySection />;
};
@@ -0,0 +1,129 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FilterableSelect, FormControl } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetAvailableOrgIdentities, useGetOrgRoles } from "@app/hooks/api";
import { useCreateOrgIdentityMembership } from "@app/hooks/api/orgIdentityMembership";
const schema = z
.object({
identity: z.object({ name: z.string(), id: z.string() }),
role: z.object({ name: z.string(), slug: z.string() })
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
onClose: () => void;
};
export const IdentityLinkForm = ({ onClose }: Props) => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { data: roles } = useGetOrgRoles(orgId);
const { mutateAsync: createMutateAsync } = useCreateOrgIdentityMembership();
const { data: rootOrgIdentities, isPending: isRootOrgLoading } = useGetAvailableOrgIdentities();
const {
control,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {}
});
const onFormSubmit = async ({ identity, role }: FormData) => {
try {
await createMutateAsync({
identityId: identity.id,
roles: [{ role: role.slug, isTemporary: false }]
});
createNotification({
text: "Successfully linked identity",
type: "success"
});
navigate({
to: "/organization/identities/$identityId",
params: {
identityId: identity.id
}
});
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to link identity";
createNotification({
text,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="identity"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}>
<FilterableSelect
value={value}
onChange={onChange}
placeholder="Select identity..."
options={rootOrgIdentities}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
isLoading={isRootOrgLoading}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
value={value}
onChange={onChange}
options={roles}
placeholder="Select role..."
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
// menuPortalTarget={document.body}
/>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Link
</Button>
<Button colorSchema="secondary" variant="plain" onClick={() => onClose()}>
Cancel
</Button>
</div>
</form>
);
};
@@ -53,6 +53,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const orgId = currentOrg?.id || "";
const { data: roles } = useGetOrgRoles(orgId);
const isOrgIdentity = popUp?.identity?.data ? orgId === popUp?.identity?.data?.orgId : true;
const { mutateAsync: createMutateAsync } = useCreateIdentity();
const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
@@ -113,6 +114,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
name: string;
role: string;
hasDeleteProtection: boolean;
orgId: string;
};
if (identity) {
@@ -196,16 +198,23 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="Machine 1" />
</FormControl>
)}
/>
{isOrgIdentity && (
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-4"
label="Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Machine 1" />
</FormControl>
)}
/>
)}
<Controller
control={control}
name="role"
@@ -214,7 +223,6 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
label={`${popUp?.identity?.data ? "Update" : ""} Role`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
placeholder="Select role..."
@@ -227,24 +235,26 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
name="hasDeleteProtection"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<Switch
className="mr-2 ml-0 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
containerClassName="flex-row-reverse w-fit"
id="delete-protection-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>Delete Protection {value ? "Enabled" : "Disabled"}</p>
</Switch>
</FormControl>
)}
/>
{isOrgIdentity && (
<Controller
control={control}
name="hasDeleteProtection"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<Switch
className="mr-2 ml-0 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
containerClassName="flex-row-reverse w-fit"
id="delete-protection-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>Delete Protection {value ? "Enabled" : "Disabled"}</p>
</Switch>
</FormControl>
)}
/>
)}
<div>
<FormLabel label="Metadata" />
</div>
@@ -1,10 +1,15 @@
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import {
faArrowUpRightFromSquare,
faBookOpen,
faLink,
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 { OrgPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { Button, DeleteActionModal, Modal, ModalContent } from "@app/components/v2";
import {
OrgPermissionIdentityActions,
OrgPermissionSubjects,
@@ -19,6 +24,7 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { IdentityAuthTemplateModal } from "./IdentityAuthTemplateModal";
import { IdentityAuthTemplatesTable } from "./IdentityAuthTemplatesTable";
import { IdentityLinkForm } from "./IdentityLinkForm";
import { IdentityModal } from "./IdentityModal";
import { IdentityTable } from "./IdentityTable";
import { IdentityTokenAuthTokenModal } from "./IdentityTokenAuthTokenModal";
@@ -27,7 +33,7 @@ import { MachineAuthTemplateUsagesModal } from "./MachineAuthTemplateUsagesModal
export const IdentitySection = withPermission(
() => {
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const orgId = currentOrg?.id || "";
const { mutateAsync: deleteMutateAsync } = useDeleteIdentity();
@@ -43,7 +49,8 @@ export const IdentitySection = withPermission(
"createTemplate",
"editTemplate",
"deleteTemplate",
"viewUsages"
"viewUsages",
"linkIdentity"
] as const);
const isMoreIdentitiesAllowed = subscription?.identityLimit
@@ -105,8 +112,8 @@ export const IdentitySection = withPermission(
return (
<div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<div className="mb-4 flex w-full items-center gap-4">
<div className="flex flex-1 items-center gap-1">
<p className="text-xl font-medium text-mineshaft-100">Identities</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
@@ -123,6 +130,26 @@ export const IdentitySection = withPermission(
</div>
</a>
</div>
{isSubOrganization && (
<OrgPermissionCan
I={OrgPermissionIdentityActions.Create}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<Button
variant="plain"
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faLink} />}
onClick={() => {
handlePopUpOpen("linkIdentity");
}}
isDisabled={!isAllowed}
>
Link Identity
</Button>
)}
</OrgPermissionCan>
)}
<OrgPermissionCan
I={OrgPermissionIdentityActions.Create}
a={OrgPermissionSubjects.Identity}
@@ -210,16 +237,18 @@ export const IdentitySection = withPermission(
?.name || ""
}
/>
{/* <IdentityAuthMethodModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/> */}
{/* <IdentityUniversalAuthClientSecretModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/> */}
<Modal
isOpen={popUp.linkIdentity.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("linkIdentity", isOpen)}
>
<ModalContent
title="Assign Existing Identity"
subTitle="Assign an existing identity from your root organization to the sub organization. The identity will continue to be managed at its original scope."
bodyClassName="overflow-visible"
>
<IdentityLinkForm onClose={() => handlePopUpClose("linkIdentity")} />
</ModalContent>
</Modal>
<IdentityTokenAuthTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
import {
faArrowDown,
faArrowUp,
faBuilding,
faCheckCircle,
faChevronRight,
faEdit,
@@ -78,7 +79,7 @@ type Filter = {
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const {
offset,
@@ -286,15 +287,18 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
</IconButton>
</div>
</Th>
{isSubOrganization && <Th>Managed By</Th>}
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="org-identities" />}
{isPending && (
<TableSkeleton columns={isSubOrganization ? 4 : 3} innerKey="org-identities" />
)}
{!isPending &&
data?.identities?.map(
({
identity: { id, name },
identity: { id, name, orgId },
role,
customRole,
lastLoginAuthMethod,
@@ -362,6 +366,14 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
}}
</OrgPermissionCan>
</Td>
{isSubOrganization && (
<Td>
<p className="truncate">
<FontAwesomeIcon size="sm" className="mr-1.5" icon={faBuilding} />
{currentOrg.id === orgId ? "Sub Organization" : "Root Organization"}
</p>
</Td>
)}
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -1,17 +1,5 @@
import { motion } from "framer-motion";
import { OrgMembersSection } from "./components";
export const OrgMembersTab = () => {
return (
<motion.div
key="panel-org-members"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<OrgMembersSection />
</motion.div>
);
return <OrgMembersSection />;
};
@@ -0,0 +1,280 @@
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 { RoleOption } from "@app/components/roles";
import { Button, FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { findOrgMembershipRole } from "@app/helpers/roles";
import {
useAddUsersToOrg,
useAddUserToWsNonE2EE,
useGetOrgRoles,
useGetUserProjects
} from "@app/hooks/api";
import { useGetAvailableOrgUsers } from "@app/hooks/api/organization/queries";
import { ProjectType, ProjectVersion } from "@app/hooks/api/projects/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
const DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG = "member";
const addMemberFormSchema = z.object({
users: z
.array(
z.object({
username: z.string().trim(),
email: z.string().trim()
})
)
.min(1),
projects: z
.array(
z.object({
name: z.string(),
id: z.string(),
slug: z.string(),
version: z.nativeEnum(ProjectVersion)
})
)
.default([]),
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
organizationRole: z.object({
name: z.string(),
slug: z.string(),
description: z.string().optional()
})
});
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
type Props = {
onClose: () => void;
};
export const AddSubOrgMemberModal = ({ onClose }: Props) => {
const { currentOrg } = useOrganization();
const { data: organizationRoles } = useGetOrgRoles(currentOrg?.id ?? "");
const { data: members = [], isPending: isMembersPending } = useGetAvailableOrgUsers();
const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg();
const { mutateAsync: addUserToProject } = useAddUserToWsNonE2EE();
const { data: projects, isPending: isProjectsLoading } = useGetUserProjects({
includeRoles: true
});
const {
control,
handleSubmit,
watch,
reset,
formState: { isSubmitting }
} = useForm<TAddMemberForm>({
resolver: zodResolver(addMemberFormSchema)
});
// set initial form role based off org default role
useEffect(() => {
if (organizationRoles) {
reset({
organizationRole: findOrgMembershipRole(organizationRoles, currentOrg.defaultMembershipRole)
});
}
}, [organizationRoles]);
const onAddMembers = async ({
users,
organizationRole,
projects: selectedProjects,
projectRoleSlug
}: TAddMemberForm) => {
if (!currentOrg?.id) return;
if (selectedProjects?.length) {
// eslint-disable-next-line no-restricted-syntax
for (const project of selectedProjects) {
if (project.version !== ProjectVersion.V3) {
createNotification({
type: "error",
text: `Cannot add users to project "${project.name}" because it's incompatible. Please upgrade the project.`
});
return;
}
}
}
try {
const usernames = users.map((el) => el.username);
await addUsersMutateAsync({
organizationId: currentOrg?.id,
inviteeEmails: usernames,
organizationRoleSlug: organizationRole.slug
});
await Promise.allSettled(
selectedProjects.map((el) =>
addUserToProject({
orgId: currentOrg.id,
projectId: el.id,
roleSlugs: [projectRoleSlug],
usernames
})
)
);
onClose();
} catch (error) {
console.error(error);
createNotification({
text: "Failed to add user to suborganization",
type: "error"
});
}
};
const getGroupHeaderLabel = (type: ProjectType) => {
switch (type) {
case ProjectType.SecretManager:
return "Secrets";
case ProjectType.CertificateManager:
return "PKI";
case ProjectType.KMS:
return "KMS";
case ProjectType.SSH:
return "SSH";
default:
return "Other";
}
};
return (
<form onSubmit={handleSubmit(onAddMembers)}>
<Controller
control={control}
name="users"
render={({ field, fieldState: { error } }) => (
<FormControl label="Emails" isError={Boolean(error)} errorText={error?.message}>
<FilterableSelect
className="w-full"
placeholder="Add one or more users..."
isMulti
name="members"
isLoading={isMembersPending}
options={members}
value={field.value}
onChange={field.onChange}
getOptionValue={(option) => option.username}
getOptionLabel={(option) => option.username}
/* eslint-disable-next-line react/no-unstable-nested-components */
noOptionsMessage={() => (
<p>All root organization users are already assigned to this project</p>
)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="organizationRole"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="Select which organization role you want to assign to the user."
label="Assign organization role"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
placeholder="Select role..."
options={organizationRoles}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
value={value}
onChange={onChange}
components={{ Option: RoleOption }}
/>
</FormControl>
)}
/>
<div className="flex items-start justify-between gap-2">
<div className="w-full">
<Controller
control={control}
name="projects"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Assign users to projects"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
>
<FilterableSelect
isMulti
value={value}
onChange={onChange}
isLoading={isProjectsLoading}
getOptionLabel={(project) => project.name}
getOptionValue={(project) => project.id}
options={projects}
groupBy="type"
getGroupHeaderLabel={getGroupHeaderLabel}
placeholder="Select projects..."
/>
</FormControl>
)}
/>
</div>
<div className="mt-[0.15rem] flex min-w-fit justify-end">
<Controller
control={control}
name="projectRoleSlug"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Select which role to assign to the users in the selected projects."
label="Role"
isError={Boolean(error)}
errorText={error?.message}
>
<div>
<Select
isDisabled={watch("projects", []).length === 0}
defaultValue={DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG}
{...field}
onValueChange={(val) => field.onChange(val)}
>
{Object.entries(ProjectMembershipRole).map(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
([_, slug]) =>
slug !== "custom" && (
<SelectItem key={slug} value={slug}>
<span className="capitalize">{slug.replace("-", " ")}</span>
</SelectItem>
)
)}
</Select>
</div>
</FormControl>
)}
/>
</div>
</div>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add Member
</Button>
<Button colorSchema="secondary" variant="plain" onClick={onClose}>
Cancel
</Button>
</div>
</form>
);
};
@@ -11,6 +11,8 @@ import {
Button,
DeleteActionModal,
EmailServiceSetupModal,
Modal,
ModalContent,
Tooltip
} from "@app/components/v2";
import {
@@ -26,11 +28,12 @@ import { OrgUser } from "@app/hooks/api/users/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddOrgMemberModal } from "./AddOrgMemberModal";
import { AddSubOrgMemberModal } from "./AddSubOrgMemberModal";
import { OrgMembersTable } from "./OrgMembersTable";
export const OrgMembersSection = () => {
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const orgId = currentOrg?.id ?? "";
const { user } = useUser();
const userId = user?.id || "";
@@ -41,6 +44,7 @@ export const OrgMembersSection = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addMember",
"addMemberToSubOrg",
"removeMember",
"deactivateMember",
"upgradePlan",
@@ -210,7 +214,9 @@ export const OrgMembersSection = () => {
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddMemberModal()}
onClick={() =>
isSubOrganization ? handlePopUpOpen("addMemberToSubOrg") : handleAddMemberModal()
}
isDisabled={!isAllowed}
>
Add Member
@@ -230,6 +236,14 @@ export const OrgMembersSection = () => {
completeInviteLinks={completeInviteLinks}
setCompleteInviteLinks={setCompleteInviteLinks}
/>
<Modal
isOpen={popUp.addMemberToSubOrg.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addMemberToSubOrg", isOpen)}
>
<ModalContent title="Add member from your organization">
<AddSubOrgMemberModal onClose={() => handlePopUpClose("addMemberToSubOrg")} />
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
title={`Are you sure you want to remove member with username ${
@@ -109,7 +109,7 @@ export const OrgMembersTable = ({
}: Props) => {
const navigate = useNavigate();
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const { user } = useUser();
const userId = user?.id || "";
const orgId = currentOrg?.id || "";
@@ -586,6 +586,7 @@ export const OrgMembersTable = ({
{isActive &&
(status === "invited" || status === "verified") &&
email &&
!isSubOrganization &&
serverDetails?.emailConfigured && (
<OrgPermissionCan
I={OrgPermissionActions.Edit}
@@ -1,17 +1,5 @@
import { motion } from "framer-motion";
import { OrgRoleTable } from "./OrgRoleTable";
export const OrgRoleTabSection = () => {
return (
<motion.div
key="role-list"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<OrgRoleTable />
</motion.div>
);
return <OrgRoleTable />;
};
@@ -68,7 +68,7 @@ enum RolesOrderBy {
export const OrgRoleTable = () => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const orgId = currentOrg?.id || "";
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -200,7 +200,9 @@ export const OrgRoleTable = () => {
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-medium text-mineshaft-100">Organization Roles</p>
<p className="text-xl font-medium text-mineshaft-100">
{isSubOrganization ? "Sub-" : ""}Organization Roles
</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Role}>
{(isAllowed) => (
<Button
@@ -3,6 +3,7 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PageHeader } from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
OrgPermissionAppConnectionActions,
OrgPermissionSubjects
@@ -12,6 +13,8 @@ import { AppConnectionsTable } from "@app/pages/organization/AppConnections/AppC
export const AppConnectionsPage = withPermission(
() => {
const { isSubOrganization } = useOrganization();
return (
<div className="bg-bunker-800">
<Helmet>
@@ -20,9 +23,9 @@ export const AppConnectionsPage = withPermission(
<meta property="og:image" content="/images/message.png" />
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<div className="w-full max-w-8xl">
<PageHeader
scope="org"
scope={isSubOrganization ? "namespace" : "org"}
className="w-full"
title="App Connections"
description="Manage organization App Connections"
@@ -1,4 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { AppConnectionsPage } from "./AppConnectionsPage";
@@ -6,6 +7,11 @@ export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/app-connections/"
)({
component: AppConnectionsPage,
validateSearch: z.object({
error: z.string().optional(),
success: z.string().optional(),
connectionId: z.string().optional()
}),
context: () => ({
breadcrumbs: [
{
@@ -1,10 +1,13 @@
import { Helmet } from "react-helmet";
import { PageHeader } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { LogsSection } from "./components";
export const AuditLogsPage = () => {
const { isSubOrganization } = useOrganization();
return (
<div className="h-full bg-bunker-800">
<Helmet>
@@ -12,11 +15,10 @@ export const AuditLogsPage = () => {
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Helmet>
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<div className="flex w-full justify-center bg-bunker-800 pb-6 text-white">
<div className="w-full max-w-8xl">
<PageHeader
scope="org"
scope={isSubOrganization ? "namespace" : "org"}
title="Audit Logs"
description="Audit logs for security and compliance teams to monitor information access."
/>
@@ -18,7 +18,7 @@ export const BillingPage = () => {
<meta property="og:image" content="/images/message.png" />
</Helmet>
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<div className="w-full max-w-8xl">
<PageHeader
scope="org"
title={t("billing.title")}
@@ -17,15 +17,15 @@ const tabs = [
export const BillingTabGroup = withPermission(
() => {
const tabsFiltered = isInfisicalCloud()
? tabs
: [{ name: "Infisical Self-Hosted", key: "tab-infisical-cloud" }];
if (!isInfisicalCloud()) {
return <BillingCloudTab />;
}
return (
<Tabs defaultValue={tabs[0].key}>
<Tabs orientation="vertical" defaultValue={tabs[0].key}>
<TabList>
{tabsFiltered.map((tab) => (
<Tab variant="org" value={tab.key}>
{tabs.map((tab) => (
<Tab variant="org" key={tab.key} value={tab.key}>
{tab.name}
</Tab>
))}
@@ -33,19 +33,15 @@ export const BillingTabGroup = withPermission(
<TabPanel value={tabs[0].key}>
<BillingCloudTab />
</TabPanel>
{isInfisicalCloud() && (
<>
<TabPanel value={tabs[1].key}>
<BillingSelfHostedTab />
</TabPanel>
<TabPanel value={tabs[2].key}>
<BillingReceiptsTab />
</TabPanel>
<TabPanel value={tabs[3].key}>
<BillingDetailsTab />
</TabPanel>
</>
)}
<TabPanel value={tabs[1].key}>
<BillingSelfHostedTab />
</TabPanel>
<TabPanel value={tabs[2].key}>
<BillingReceiptsTab />
</TabPanel>
<TabPanel value={tabs[3].key}>
<BillingDetailsTab />
</TabPanel>
</Tabs>
);
},
@@ -1,4 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { BillingPage } from "./BillingPage";
@@ -6,7 +6,14 @@ export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/billing"
)({
component: BillingPage,
beforeLoad: () => {
beforeLoad: ({ search }) => {
if (search.subOrganization) {
throw redirect({
to: "/organization/projects",
search
});
}
return {
breadcrumbs: [
{
@@ -1,6 +1,8 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@@ -18,7 +20,7 @@ import {
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useDeleteGroup } from "@app/hooks/api";
import { useGetGroupById } from "@app/hooks/api/groups/queries";
import { usePopUp } from "@app/hooks/usePopUp";
@@ -43,6 +45,8 @@ const Page = () => {
const { data, isPending } = useGetGroupById(groupId);
const { isSubOrganization } = useOrganization();
const { mutateAsync: deleteMutateAsync } = useDeleteGroup();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -80,10 +84,24 @@ const Page = () => {
if (isPending) return <Spinner size="sm" className="mt-2 ml-2" />;
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader scope="org" title={data.group.name}>
<div className="mx-auto w-full max-w-8xl">
<Link
to="/organization/access-management"
search={{
selectedTab: TabSections.Groups
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Groups
</Link>
<PageHeader
scope={isSubOrganization ? "namespace" : "org"}
description={`${isSubOrganization ? "Sub-" : ""}Organization Group`}
title={data.group.name}
>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -1,6 +1,8 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
@@ -27,10 +29,11 @@ const Page = () => {
from: ROUTE_PATHS.Organization.IdentityDetailsByIDPage.id
});
const identityId = params.identityId as string;
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const orgId = currentOrg?.id || "";
const { data } = useGetIdentityById(identityId);
const { mutateAsync: deleteIdentity } = useDeleteIdentity();
const isAuthHidden = orgId !== data?.identity?.orgId;
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"identity",
@@ -72,17 +75,37 @@ const Page = () => {
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader scope="org" title={data.identity.name} />
<div className="mx-auto w-full max-w-8xl">
<Link
to="/organization/access-management"
search={{
selectedTab: OrgAccessControlTabSections.Identities
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Identities
</Link>
<PageHeader
scope={isSubOrganization ? "namespace" : "org"}
description={`${isSubOrganization ? "Sub-" : ""}Organization Identity`}
title={data.identity.name}
/>
<div className="flex">
<div className="mr-4 w-96">
<IdentityDetailsSection identityId={identityId} handlePopUpOpen={handlePopUpOpen} />
<IdentityAuthenticationSection
<IdentityDetailsSection
isOrgIdentity={data.identity.orgId === currentOrg.id}
identityId={identityId}
handlePopUpOpen={handlePopUpOpen}
/>
{!isAuthHidden && (
<IdentityAuthenticationSection
identityId={identityId}
handlePopUpOpen={handlePopUpOpen}
/>
)}
</div>
<IdentityProjectsSection identityId={identityId} />
</div>
@@ -21,23 +21,25 @@ import {
Tag,
Tooltip
} from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { identityAuthToNameMap, useGetIdentityById } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
identityId: string;
isOrgIdentity?: boolean;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["identity", "identityAuthMethod", "deleteIdentity"]>,
data?: object
) => void;
};
export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) => {
export const IdentityDetailsSection = ({ identityId, handlePopUpOpen, isOrgIdentity }: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { isSubOrganization } = useOrganization();
const { data } = useGetIdentityById(identityId);
return data ? (
@@ -75,6 +77,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
handlePopUpOpen("identity", {
identityId,
name: data.identity.name,
orgId: data.identity.orgId,
hasDeleteProtection: data.identity.hasDeleteProtection,
role: data.role,
customRole: data.customRole,
@@ -140,24 +143,38 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
<p className="text-sm font-medium text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{data.identity.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Last Login Auth Method</p>
<p className="text-sm text-mineshaft-300">
{data.lastLoginAuthMethod ? identityAuthToNameMap[data.lastLoginAuthMethod] : "-"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Last Login Time</p>
<p className="text-sm text-mineshaft-300">
{data.lastLoginTime ? format(data.lastLoginTime, "PPpp") : "-"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Delete Protection</p>
<p className="text-sm text-mineshaft-300">
{data.identity.hasDeleteProtection ? "On" : "Off"}
</p>
</div>
{isSubOrganization && (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Managed By</p>
<p className="text-sm text-mineshaft-300">
{isOrgIdentity ? "Organization" : "Root Organization"}
</p>
</div>
)}
{isOrgIdentity && (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Last Login Auth Method</p>
<p className="text-sm text-mineshaft-300">
{data.lastLoginAuthMethod ? identityAuthToNameMap[data.lastLoginAuthMethod] : "-"}
</p>
</div>
)}
{isOrgIdentity && (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Last Login Time</p>
<p className="text-sm text-mineshaft-300">
{data.lastLoginTime ? format(data.lastLoginTime, "PPpp") : "-"}
</p>
</div>
)}
{isOrgIdentity && (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Delete Protection</p>
<p className="text-sm text-mineshaft-300">
{data.identity.hasDeleteProtection ? "On" : "Off"}
</p>
</div>
)}
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{data.role}</p>
@@ -1,10 +1,13 @@
import { Helmet } from "react-helmet";
import { PageHeader } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { NetworkingTabGroup } from "./components/NetworkingTabGroup/NetworkingTabGroup";
export const NetworkingPage = () => {
const { isSubOrganization } = useOrganization();
return (
<>
<Helmet>
@@ -12,9 +15,9 @@ export const NetworkingPage = () => {
<meta property="og:image" content="/images/message.png" />
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<div className="w-full max-w-8xl">
<PageHeader
scope="org"
scope={isSubOrganization ? "namespace" : "org"}
title="Networking"
description="Manage gateways and relays to securely access private network resources from Infisical"
/>
@@ -2,6 +2,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useOrganization } from "@app/context";
import { GatewayTab } from "../GatewayTab/GatewayTab";
import { RelayTab } from "../RelayTab/RelayTab";
@@ -27,11 +28,13 @@ export const NetworkingTabGroup = () => {
});
};
const { isSubOrganization } = useOrganization();
return (
<Tabs value={selectedTab} onValueChange={handleTabChange}>
<Tabs orientation="vertical" value={selectedTab} onValueChange={handleTabChange}>
<TabList>
{tabs.map((tab) => (
<Tab variant="org" value={tab.key} key={tab.key}>
<Tab variant={isSubOrganization ? "namespace" : "org"} value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { NewProjectModal } from "@app/components/projects";
import { PageHeader } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { useOrganization, useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks/usePopUp";
import { AllProjectView } from "./components/AllProjectView";
@@ -52,24 +52,22 @@ export const ProjectsPage = () => {
] as const);
const { subscription } = useSubscription();
const { isSubOrganization } = useOrganization();
const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
return (
<div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800">
<div className="mx-auto flex max-w-8xl flex-col justify-start bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<div className="mb-4 flex flex-col items-start justify-start">
<PageHeader
scope="org"
title="Overview"
description="Your team's complete security toolkit - organized and ready when you need them."
/>
</div>
<PageHeader
scope={isSubOrganization ? "namespace" : "org"}
title="Overview"
description="Your team's complete security toolkit - organized and ready when you need them."
/>
{projectListView === ProjectListView.MyProjects ? (
<MyProjectView
onAddNewProject={() => handlePopUpOpen("addNewWs")}
@@ -378,7 +378,7 @@ export const MyProjectView = ({
<div className="flex w-full flex-row">
<ProjectListToggle value={projectListView} onChange={onProjectListViewChange} />
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50/60 duration-200 focus:bg-mineshaft-700/80"
containerClassName="w-full ml-2"
placeholder="Search by project name..."
value={searchFilter}
@@ -1,8 +1,8 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faCopy, faEllipsisV } from "@fortawesome/free-solid-svg-icons";
import { faChevronLeft, faCopy, faEllipsisV } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams } from "@tanstack/react-router";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
@@ -30,7 +30,7 @@ export const Page = () => {
from: ROUTE_PATHS.Organization.RoleByIDPage.id
});
const roleId = params.roleId as string;
const { currentOrg } = useOrganization();
const { currentOrg, isSubOrganization } = useOrganization();
const orgId = currentOrg?.id || "";
const { data } = useGetOrgRole(orgId, roleId);
const { mutateAsync: deleteOrgRole } = useDeleteOrgRole();
@@ -77,20 +77,26 @@ export const Page = () => {
const isCustomRole = !["admin", "member", "no-access"].includes(data?.slug ?? "");
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto w-full max-w-8xl">
<Link
to="/organization/access-management"
search={{
selectedTab: OrgAccessControlTabSections.Roles
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Roles
</Link>
<PageHeader
scope="org"
title={
<div className="flex flex-col">
<div>
<span>{data.name}</span>
<p className="text-sm leading-3 font-normal text-mineshaft-400 normal-case">
{data.slug} {data.description && `- ${data.description}`}
</p>
</div>
</div>
scope={isSubOrganization ? "namespace" : "org"}
title={data.name}
description={
<>
{data.slug} {data.description && `- ${data.description}`}
</>
}
>
{isCustomRole && (
@@ -12,6 +12,7 @@ import {
OrgPermissionKmipActions,
OrgPermissionMachineIdentityAuthTemplateActions,
OrgPermissionSecretShareAction,
OrgPermissionSubOrgActions,
OrgRelayPermissionActions
} from "@app/context/OrgPermissionContext/types";
import { TPermission } from "@app/hooks/api/roles/types";
@@ -123,6 +124,13 @@ const secretSharingPermissionSchema = z
})
.optional();
const subOrganizationPermissionSchema = z
.object({
[OrgPermissionSubOrgActions.Create]: z.boolean().optional(),
[OrgPermissionSubOrgActions.DirectAccess]: z.boolean().optional()
})
.optional();
export const formSchema = z.object({
name: z.string().trim(),
description: z.string().trim().optional(),
@@ -159,7 +167,8 @@ export const formSchema = z.object({
gateway: orgGatewayPermissionSchema,
relay: orgRelayPermissionSchema,
"machine-identity-auth-template": machineIdentityAuthTemplatePermissionSchema,
"secret-share": secretSharingPermissionSchema
"secret-share": secretSharingPermissionSchema,
"sub-organization": subOrganizationPermissionSchema
})
.optional()
});
@@ -0,0 +1,159 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
import { OrgPermissionSubOrgActions } from "@app/context/OrgPermissionContext/types";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "../OrgRoleModifySection.utils";
const PERMISSION_ACTIONS = [
{ action: OrgPermissionSubOrgActions.Create, label: "Create" },
{ action: OrgPermissionSubOrgActions.DirectAccess, label: "Direct Access" }
] as const;
type Props = {
isEditable: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
FullAccess = "full-access",
Custom = "custom"
}
export const OrgPermissionSubOrgRow = ({ isEditable, control, setValue }: Props) => {
const [isRowExpanded, setIsRowExpanded] = useToggle();
const [isCustom, setIsCustom] = useToggle();
const rule = useWatch({
control,
name: "permissions.sub-organization"
});
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = PERMISSION_ACTIONS.length;
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
if (score === totalActions) return Permission.FullAccess;
return Permission.Custom;
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
const handlePermissionChange = (val: Permission) => {
if (val === Permission.Custom) {
setIsRowExpanded.on();
setIsCustom.on();
return;
}
setIsCustom.off();
switch (val) {
case Permission.NoAccess:
setValue(
"permissions.sub-organization",
{
[OrgPermissionSubOrgActions.Create]: false,
[OrgPermissionSubOrgActions.DirectAccess]: false
},
{ shouldDirty: true }
);
break;
case Permission.FullAccess:
setValue(
"permissions.sub-organization",
{
[OrgPermissionSubOrgActions.Create]: true,
[OrgPermissionSubOrgActions.DirectAccess]: true
},
{ shouldDirty: true }
);
break;
default:
setValue(
"permissions.sub-organization",
{
[OrgPermissionSubOrgActions.Create]: true,
[OrgPermissionSubOrgActions.DirectAccess]: true
},
{ shouldDirty: true }
);
break;
}
};
return (
<>
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => setIsRowExpanded.toggle()}
>
<Td className="w-4">
<FontAwesomeIcon className="w-4" icon={isRowExpanded ? faChevronDown : faChevronRight} />
</Td>
<Td className="w-full select-none">Sub-Organizations</Td>
<Td>
<Select
value={selectedPermissionCategory}
className="h-8 w-40 bg-mineshaft-700"
dropdownContainerClassName="border text-left border-mineshaft-600 bg-mineshaft-800"
onValueChange={handlePermissionChange}
isDisabled={!isEditable}
position="popper"
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</Td>
</Tr>
{isRowExpanded && (
<Tr>
<Td colSpan={3} className="border-mineshaft-500 bg-mineshaft-900 p-8">
<div className="flex grow flex-wrap justify-start gap-x-8 gap-y-4">
{PERMISSION_ACTIONS.map(({ action, label }) => {
return (
<Controller
name={`permissions.sub-organization.${action}`}
key={`permissions.sub-organization.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={(e) => {
if (!isEditable) {
createNotification({
type: "error",
text: "Failed to update default role"
});
return;
}
field.onChange(e);
}}
id={`permissions.sub-organization.${action}`}
>
{label}
</Checkbox>
)}
/>
);
})}
</div>
</Td>
</Tr>
)}
</>
);
};
@@ -74,6 +74,7 @@ type Props = {
| "billing"
| "audit-logs"
| "machine-identity-auth-template"
| "sub-organization"
>;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
@@ -25,6 +25,7 @@ import { OrgPermissionKmipRow } from "./OrgPermissionKmipRow";
import { OrgPermissionMachineIdentityAuthTemplateRow } from "./OrgPermissionMachineIdentityAuthTemplateRow";
import { OrgRelayPermissionRow } from "./OrgPermissionRelayRow";
import { OrgPermissionSecretShareRow } from "./OrgPermissionSecretShareRow";
import { OrgPermissionSubOrgRow } from "./OrgPermissionSubOrgRow";
import { OrgRoleWorkspaceRow } from "./OrgRoleWorkspaceRow";
import { RolePermissionRow } from "./RolePermissionRow";
@@ -76,8 +77,18 @@ type Props = {
roleId: string;
};
const INVALID_SUBORG_PERMISSIONS = [
OrgPermissionSubjects.Sso,
OrgPermissionSubjects.Ldap,
OrgPermissionSubjects.Scim,
OrgPermissionSubjects.GithubOrgSync,
OrgPermissionSubjects.GithubOrgSyncManual,
OrgPermissionSubjects.Billing,
OrgPermissionSubjects.SubOrganization
];
export const RolePermissionsSection = ({ roleId }: Props) => {
const { currentOrg } = useOrganization();
const { currentOrg, isRootOrganization } = useOrganization();
const orgId = currentOrg?.id || "";
const { data: role } = useGetOrgRole(orgId, roleId);
@@ -152,7 +163,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
<TableContainer>
<Table>
<TBody>
{SIMPLE_PERMISSION_OPTIONS.map((permission) => {
{SIMPLE_PERMISSION_OPTIONS.filter((el) =>
isRootOrganization
? true
: !INVALID_SUBORG_PERMISSIONS.includes(el.formName as OrgPermissionSubjects)
).map((permission) => {
return (
<RolePermissionRow
title={permission.title}
@@ -194,11 +209,13 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
setValue={setValue}
isEditable={isCustomRole}
/>
<OrgPermissionBillingRow
control={control}
setValue={setValue}
isEditable={isCustomRole}
/>
{isRootOrganization && (
<OrgPermissionBillingRow
control={control}
setValue={setValue}
isEditable={isCustomRole}
/>
)}
<OrgPermissionSecretShareRow
control={control}
setValue={setValue}
@@ -224,6 +241,13 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
setValue={setValue}
isEditable={isCustomRole}
/>
{isRootOrganization && (
<OrgPermissionSubOrgRow
control={control}
setValue={setValue}
isEditable={isCustomRole}
/>
)}
</TBody>
</Table>
</TableContainer>
@@ -4,12 +4,13 @@ import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PageHeader } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { ShareSecretSection } from "./ShareSecretSection";
export const SecretSharingPage = () => {
const { t } = useTranslation();
const { isSubOrganization } = useOrganization();
return (
<>
<Helmet>
@@ -20,9 +21,9 @@ export const SecretSharingPage = () => {
<meta name="og:description" content={String(t("approval.og-description"))} />
</Helmet>
<div className="h-full">
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 text-white">
<div className="mx-auto h-full w-full max-w-8xl bg-bunker-800 text-white">
<PageHeader
scope="org"
scope={isSubOrganization ? "namespace" : "org"}
title="Secret Sharing"
description="Share secrets securely using a shareable link"
>
@@ -1,19 +1,23 @@
import { Helmet } from "react-helmet";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { Badge, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useOrganization } from "@app/context";
import { RequestSecretTab } from "./components/RequestSecret/RequestSecretTab";
import { SecretSharingSettingsTab } from "./components/SecretSharingSettings/SecretSharingSettingsTab";
import { ShareSecretTab } from "./components/ShareSecret/ShareSecretTab";
enum SecretSharingPageTabs {
ShareSecret = "share-secret",
RequestSecret = "request-secret"
RequestSecret = "request-secret",
Settings = "settings"
}
export const ShareSecretSection = () => {
const navigate = useNavigate();
const { isSubOrganization } = useOrganization();
const { selectedTab } = useSearch({
from: ROUTE_PATHS.Organization.SecretSharing.id
@@ -26,6 +30,8 @@ export const ShareSecretSection = () => {
});
};
const tabVariant = isSubOrganization ? "namespace" : "org";
return (
<div>
<Helmet>
@@ -34,15 +40,19 @@ export const ShareSecretSection = () => {
<meta property="og:image" content="/images/message.png" />
</Helmet>
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
<Tabs orientation="vertical" value={selectedTab} onValueChange={updateSelectedTab}>
<TabList>
<Tab value={SecretSharingPageTabs.ShareSecret}>Share Secrets</Tab>
<Tab value={SecretSharingPageTabs.RequestSecret}>
Request Secrets
<Badge variant="primary" className="ml-1 cursor-pointer text-xs">
New
</Badge>
<Tab variant={tabVariant} value={SecretSharingPageTabs.ShareSecret}>
Share Secrets
</Tab>
<Tab variant={tabVariant} value={SecretSharingPageTabs.RequestSecret}>
Request Secrets
</Tab>
{!isSubOrganization && (
<Tab variant={tabVariant} value={SecretSharingPageTabs.Settings}>
Settings
</Tab>
)}
</TabList>
<TabPanel value={SecretSharingPageTabs.ShareSecret}>
<ShareSecretTab />
@@ -50,6 +60,9 @@ export const ShareSecretSection = () => {
<TabPanel value={SecretSharingPageTabs.RequestSecret}>
<RequestSecretTab />
</TabPanel>
<TabPanel value={SecretSharingPageTabs.Settings}>
<SecretSharingSettingsTab />
</TabPanel>
</Tabs>
</div>
);
@@ -3,6 +3,7 @@ import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy, faRedo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useSearch } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
@@ -36,6 +37,10 @@ export const RequestSecretForm = () => {
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset<string>({
initialState: "Copy to clipboard"
});
const subOrganization = useSearch({
strict: false,
select: (el) => el?.subOrganization
});
const { mutateAsync: createSecretRequest } = useCreateSecretRequest();
@@ -58,12 +63,15 @@ export const RequestSecretForm = () => {
expiresAt
});
const link = `${window.location.origin}/secret-request/secret/${id}`;
const link = new URL(`${window.location.origin}/secret-request/secret/${id}`);
if (subOrganization) {
link.searchParams.set("subOrganization", subOrganization);
}
setSecretLink(link);
setSecretLink(link.toString());
reset();
navigator.clipboard.writeText(link);
navigator.clipboard.writeText(link.toString());
setCopyTextSecret("secret");
createNotification({
@@ -0,0 +1,21 @@
import { OrgPermissionSubjects } from "@app/context";
import { OrgPermissionSecretShareAction } from "@app/context/OrgPermissionContext/types";
import { withPermission } from "@app/hoc";
import { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection";
import { SecretSharingAllowShareToAnyone } from "./SecretSharingAllowShareToAnyone";
export const SecretSharingSettingsTab = withPermission(
() => {
return (
<div className="w-full">
<SecretSharingAllowShareToAnyone />
<OrgSecretShareLimitSection />
</div>
);
},
{
action: OrgPermissionSecretShareAction.ManageSettings,
subject: OrgPermissionSubjects.SecretShare
}
);
@@ -1,35 +0,0 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import {
OrgPermissionSecretShareAction,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import { withPermission } from "@app/hoc";
import { SecretSharingSettingsTabGroup } from "./components";
export const SecretSharingSettingsPage = withPermission(
() => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "Secret Share Settings" })}</title>
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<PageHeader scope="org" title="Secret Share Settings" />
<SecretSharingSettingsTabGroup />
</div>
</div>
</>
);
},
{
action: OrgPermissionSecretShareAction.ManageSettings,
subject: OrgPermissionSubjects.SecretShare
}
);
@@ -1 +0,0 @@
export { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection";
@@ -1 +0,0 @@
export { SecretSharingAllowShareToAnyone } from "./SecretSharingAllowShareToAnyone";
@@ -1,11 +0,0 @@
import { OrgSecretShareLimitSection } from "../OrgSecretShareLimitSection";
import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone";
export const SecretSharingSettingsGeneralTab = () => {
return (
<div className="w-full">
<SecretSharingAllowShareToAnyone />
<OrgSecretShareLimitSection />
</div>
);
};
@@ -1 +0,0 @@
export { SecretSharingSettingsGeneralTab } from "./SecretSharingSettingsGeneralTab";
@@ -1,39 +0,0 @@
import { useState } from "react";
import { useSearch } from "@tanstack/react-router";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { SecretSharingSettingsGeneralTab } from "../SecretSharingSettingsGeneralTab";
export const SecretSharingSettingsTabGroup = () => {
const search = useSearch({
from: ROUTE_PATHS.Organization.SecretSharingSettings.id
});
const tabs = [
{
name: "General",
key: "tab-secret-sharing-general",
component: SecretSharingSettingsGeneralTab
}
];
const [selectedTab, setSelectedTab] = useState(search.selectedTab || tabs[0].key);
return (
<Tabs value={selectedTab} onValueChange={setSelectedTab}>
<TabList>
{tabs.map((tab) => (
<Tab value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
</TabList>
{tabs.map(({ key, component: Component }) => (
<TabPanel value={key} key={`tab-panel-${key}`}>
<Component />
</TabPanel>
))}
</Tabs>
);
};
@@ -1 +0,0 @@
export { SecretSharingSettingsTabGroup } from "./SecretSharingSettingsTabGroup";
@@ -1 +0,0 @@
export { SecretSharingSettingsTabGroup } from "./SecretSharingSettingsTabGroup";
@@ -1,30 +0,0 @@
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SecretSharingSettingsPage } from "./SecretSharingSettingsPage";
const SettingsPageQueryParams = z.object({
selectedTab: z.string().catch("")
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/settings"
)({
component: SecretSharingSettingsPage,
validateSearch: zodValidator(SettingsPageQueryParams),
search: {
middlewares: [stripSearchParams({ selectedTab: "" })]
},
context: () => ({
breadcrumbs: [
{
label: "Secret Sharing",
link: linkOptions({ to: "/organization/secret-sharing" })
},
{
label: "Settings"
}
]
})
});
@@ -2,11 +2,13 @@ import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { OrgTabGroup } from "./components";
export const SettingsPage = () => {
const { t } = useTranslation();
const { isSubOrganization } = useOrganization();
return (
<>
@@ -14,8 +16,12 @@ export const SettingsPage = () => {
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<PageHeader scope="org" title={t("settings.org.title")} />
<div className="w-full max-w-8xl">
<PageHeader
scope={isSubOrganization ? "namespace" : "org"}
description="Configure organization-wide settings"
title={t("settings.org.title")}
/>
<OrgTabGroup />
</div>
</div>
@@ -55,7 +55,7 @@ export const OrgDeleteSection = () => {
onClick={() => handlePopUpOpen("deleteOrg")}
isDisabled={Boolean(!hasOrgRole(OrgMembershipRole.Admin))}
>
{`Delete ${currentOrg?.name}`}
{`Delete ${currentOrg.subOrganization?.name ?? currentOrg?.name}`}
</Button>
</div>
<DeleteActionModal
@@ -1,16 +1,17 @@
import { useOrgPermission } from "@app/context";
import { useOrganization, useOrgPermission } from "@app/context";
import { OrgMembershipRole } from "@app/helpers/roles";
import { OrgDeleteSection } from "../OrgDeleteSection";
import { OrgIncidentContactsSection } from "../OrgIncidentContactsSection";
import { OrgNameChangeSection } from "../OrgNameChangeSection";
import { OrgNameChangeSection, SubOrgNameChangeSection } from "../OrgNameChangeSection";
export const OrgGeneralTab = () => {
const { hasOrgRole } = useOrgPermission();
const { isSubOrganization } = useOrganization();
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<OrgNameChangeSection />
<OrgIncidentContactsSection />
{isSubOrganization ? <SubOrgNameChangeSection /> : <OrgNameChangeSection />}
{!isSubOrganization && <OrgIncidentContactsSection />}
{hasOrgRole(OrgMembershipRole.Admin) && <OrgDeleteSection />}
</div>
);

Some files were not shown because too many files have changed in this diff Show More