mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-21 02:49:06 +00:00
Merge branch 'main' into ENG-3955
This commit is contained in:
@@ -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."
|
||||
/>
|
||||
|
||||
+21
-4
@@ -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
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
-20
@@ -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=""
|
||||
|
||||
+526
@@ -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)"
|
||||
|
||||
+15
-3
@@ -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 || "";
|
||||
};
|
||||
+179
@@ -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
|
||||
|
||||
+21
-4
@@ -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">
|
||||
|
||||
+21
-4
@@ -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."
|
||||
/>
|
||||
|
||||
+24
-18
@@ -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'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>
|
||||
);
|
||||
};
|
||||
+127
@@ -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>
|
||||
);
|
||||
};
|
||||
+609
@@ -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----- MIIDXTCCAkWgAwIBAgIJAKoK/heBjcOuMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX ... -----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>
|
||||
);
|
||||
};
|
||||
+101
@@ -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>
|
||||
);
|
||||
};
|
||||
+236
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export { CertificateProfilesTab } from "./CertificateProfilesTab";
|
||||
export { CreateProfileModal } from "./CreateProfileModal";
|
||||
export { ProfileList } from "./ProfileList";
|
||||
+123
@@ -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>
|
||||
);
|
||||
};
|
||||
+961
@@ -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 "Add Attribute" 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 "Add SAN" 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>
|
||||
);
|
||||
};
|
||||
+144
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
|
||||
export { CreateTemplateModal } from "./CreateTemplateModal";
|
||||
export { TemplateList } from "./TemplateList";
|
||||
+241
@@ -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;
|
||||
};
|
||||
+155
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export * from "./components";
|
||||
export * from "./schemas";
|
||||
export * from "./utils";
|
||||
+150
@@ -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>;
|
||||
+51
@@ -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,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
-13
@@ -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
-13
@@ -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 />;
|
||||
};
|
||||
|
||||
+129
@@ -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>
|
||||
);
|
||||
};
|
||||
+39
-29
@@ -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>
|
||||
|
||||
+45
-16
@@ -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}
|
||||
|
||||
+15
-3
@@ -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
-13
@@ -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 />;
|
||||
};
|
||||
|
||||
+280
@@ -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>
|
||||
);
|
||||
};
|
||||
+16
-2
@@ -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 ${
|
||||
|
||||
+2
-1
@@ -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
-13
@@ -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 />;
|
||||
};
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+5
-2
@@ -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")}
|
||||
|
||||
+15
-19
@@ -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">
|
||||
|
||||
+30
-7
@@ -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>
|
||||
|
||||
+37
-20
@@ -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"
|
||||
/>
|
||||
|
||||
+5
-2
@@ -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 && (
|
||||
|
||||
+10
-1
@@ -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()
|
||||
});
|
||||
|
||||
+159
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -74,6 +74,7 @@ type Props = {
|
||||
| "billing"
|
||||
| "audit-logs"
|
||||
| "machine-identity-auth-template"
|
||||
| "sub-organization"
|
||||
>;
|
||||
setValue: UseFormSetValue<TFormSchema>;
|
||||
control: Control<TFormSchema>;
|
||||
|
||||
+31
-7
@@ -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>
|
||||
);
|
||||
|
||||
+11
-3
@@ -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({
|
||||
|
||||
+21
@@ -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
|
||||
}
|
||||
);
|
||||
-35
@@ -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
@@ -1 +0,0 @@
|
||||
export { OrgSecretShareLimitSection } from "./OrgSecretShareLimitSection";
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { SecretSharingAllowShareToAnyone } from "./SecretSharingAllowShareToAnyone";
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { OrgSecretShareLimitSection } from "../OrgSecretShareLimitSection";
|
||||
import { SecretSharingAllowShareToAnyone } from "../SecretSharingAllowShareToAnyone";
|
||||
|
||||
export const SecretSharingSettingsGeneralTab = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<SecretSharingAllowShareToAnyone />
|
||||
<OrgSecretShareLimitSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { SecretSharingSettingsGeneralTab } from "./SecretSharingSettingsGeneralTab";
|
||||
-39
@@ -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
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+5
-4
@@ -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
Reference in New Issue
Block a user