Merge branch 'heads/main' into daniel/k8s-auth-gateway

This commit is contained in:
Daniel Hougaard
2025-05-14 14:26:05 +04:00
330 changed files with 15686 additions and 6256 deletions
@@ -15,7 +15,10 @@ export const AlertingPage = () => {
<title>{t("common.head-title", { title: "Alerting" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader title="Alerting" />
<PageHeader
title="Alerting"
description="Configure alerts for expiring certificates and CAs to maintain security and compliance."
/>
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionActions.Read}
@@ -63,7 +63,7 @@ const Page = () => {
handlePopUpClose("deleteCa");
navigate({
to: `/${ProjectType.CertificateManager}/$projectId/overview` as const,
to: `/${ProjectType.CertificateManager}/$projectId/certificates` as const,
params: {
projectId
}
@@ -15,7 +15,10 @@ export const CertificateAuthoritiesPage = () => {
<title>{t("common.head-title", { title: "Certificate Authorities" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader title="Certificate Authorities" />
<PageHeader
title="Certificate Authorities"
description="Manage internal private certificate authorities for issuing and signing certificates, including root and intermediate CAs."
/>
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionActions.Read}
@@ -32,7 +32,10 @@ export const CertificatesPage = () => {
<title>{t("common.head-title", { title: "Certificates" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader title="Certificates" />
<PageHeader
title="Certificates"
description="View and track issued certificates, monitor expiration dates, and manage certificate lifecycles."
/>
{/* If both are false, the section does not render. This is to prevent duplicate banners. */}
{(canAccessCerts || canAccessPkiColl) && (
<ProjectPermissionCan
@@ -71,7 +71,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<Table>
<THead>
<Tr>
<Th>Friendly Name</Th>
<Th>Common Name</Th>
<Th>Status</Th>
<Th>Not Before</Th>
<Th>Not After</Th>
@@ -85,7 +85,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter);
return (
<Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.friendlyName}</Td>
<Td>{certificate.commonName}</Td>
<Td>
{certificate.status === CertStatus.REVOKED ? (
<Badge variant="danger">Revoked</Badge>
@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { CertificatesPage } from "./CertificatesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview"
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificates"
)({
component: CertificatesPage
});
@@ -57,7 +57,7 @@ export const PkiCollectionPage = () => {
});
handlePopUpClose("deletePkiCollection");
navigate({
to: `/${ProjectType.CertificateManager}/$projectId/overview` as const,
to: `/${ProjectType.CertificateManager}/$projectId/certificates` as const,
params: {
projectId
}
@@ -13,7 +13,7 @@ export const Route = createFileRoute(
{
label: "Certificate Collections",
link: linkOptions({
to: "/cert-manager/$projectId/overview",
to: "/cert-manager/$projectId/certificates",
params: {
projectId: params.projectId
}
@@ -0,0 +1,165 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
PageHeader,
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
useWorkspace
} from "@app/context";
import { useDeletePkiSubscriber, useGetPkiSubscriber } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { PkiSubscriberModal } from "../PkiSubscribersPage/components/PkiSubscriberModal";
import { PkiSubscriberCertificatesSection, PkiSubscriberDetailsSection } from "./components";
const Page = () => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace.id;
const subscriberName = useParams({
from: ROUTE_PATHS.CertManager.PkiSubscriberDetailsByIDPage.id,
select: (el) => el.subscriberName
});
const { data } = useGetPkiSubscriber({
subscriberName,
projectId
});
const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"pkiSubscriber",
"deletePkiSubscriber"
] as const);
const onRemoveSubscriberSubmit = async (subscriberNameToDelete: string) => {
try {
if (!projectId) return;
await deletePkiSubscriber({ subscriberName: subscriberNameToDelete, projectId });
createNotification({
text: "Successfully deleted subscriber",
type: "success"
});
handlePopUpClose("deletePkiSubscriber");
navigate({
to: `/${ProjectType.CertificateManager}/$projectId/subscribers` as const,
params: {
projectId
}
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete subscriber",
type: "error"
});
}
};
return (
<div className="container 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 title={data.name}>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<Button variant="outline_bg">More</Button>
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Delete}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("deletePkiSubscriber", {
subscriberName: data.name
})
}
disabled={!isAllowed}
>
Delete PKI Subscriber
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<PkiSubscriberDetailsSection
subscriberName={data.name}
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<div className="w-full">
<PkiSubscriberCertificatesSection subscriberName={data.name} />
</div>
</div>
</div>
)}
<PkiSubscriberModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deletePkiSubscriber.isOpen}
title={`Are you sure you want to remove the PKI subscriber: ${
(popUp?.deletePkiSubscriber?.data as { name: string })?.name || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deletePkiSubscriber", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSubscriberSubmit(
(popUp?.deletePkiSubscriber?.data as { subscriberName: string })?.subscriberName
)
}
/>
</div>
);
};
export const PkiSubscriberDetailsByIDPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "PKI Subscriber" })}</title>
</Helmet>
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Read}
a={ProjectPermissionSub.PkiSubscribers}
passThrough={false}
renderGuardBanner
>
<Page />
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,27 @@
import { usePopUp } from "@app/hooks";
import { CertificateRevocationModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateRevocationModal";
import { PkiSubscriberCertificatesTable } from "./PkiSubscriberCertificatesTable";
type Props = {
subscriberName: string;
};
export const PkiSubscriberCertificatesSection = ({ subscriberName }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["revokeCertificate"] as const);
return (
<div className="h-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Certificates</h3>
</div>
<div className="py-4">
<PkiSubscriberCertificatesTable
subscriberName={subscriberName}
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
</div>
);
};
@@ -0,0 +1,176 @@
import { useState } from "react";
import { subject } from "@casl/ability";
import { faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { useGetPkiSubscriberCertificates } from "@app/hooks/api";
import { CertStatus } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
subscriberName: string;
handlePopUpOpen?: (popUpName: keyof UsePopUpState<["revokeCertificate"]>, data?: object) => void;
};
const PER_PAGE_INIT = 25;
export const PkiSubscriberCertificatesTable = ({ subscriberName, handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace.id;
const { permission } = useProjectPermission();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
const { data, isPending } = useGetPkiSubscriberCertificates({
subscriberName,
projectId,
offset: (page - 1) * perPage,
limit: perPage
});
const getCertStatusBadge = (status: string, notAfter: string) => {
if (status === CertStatus.REVOKED) {
return <Badge variant="danger">Revoked</Badge>;
}
const expiryDate = new Date(notAfter);
const now = new Date();
const daysUntilExpiry = Math.floor(
(expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysUntilExpiry < 0) {
return <Badge variant="danger">Expired</Badge>;
}
if (daysUntilExpiry < 30) {
return <Badge variant="primary">Expiring Soon</Badge>;
}
return <Badge variant="success">Valid</Badge>;
};
const canListPkiSubscriberCerts = permission.can(
ProjectPermissionPkiSubscriberActions.ListCerts,
subject(ProjectPermissionSub.PkiSubscribers, {
name: subscriberName
})
);
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Common Name</Th>
<Th>Status</Th>
<Th>Not Before</Th>
<Th>Not After</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={5} innerKey="pki-subscriber-certificates" />}
{!isPending &&
data?.certificates?.map((certificate) => {
return (
<Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.commonName}</Td>
<Td>{getCertStatusBadge(certificate.status, certificate.notAfter)}</Td>
<Td>
{certificate.notBefore
? format(new Date(certificate.notBefore), "yyyy-MM-dd")
: "-"}
</Td>
<Td>
{certificate.notAfter
? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"}
</Td>
<Td className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Delete}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen &&
handlePopUpOpen("revokeCertificate", {
serialNumber: certificate.serialNumber
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Revoke Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data?.totalCount !== undefined && data.totalCount >= PER_PAGE_INIT && (
<Pagination
count={data.totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && !data?.certificates?.length && (
<EmptyState
title={`${canListPkiSubscriberCerts ? "No certificates have been issued for this subscriber" : "You do not have permission to view this subscriber's certificates"}`}
icon={faCertificate}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1,190 @@
import { useState } from "react";
import { subject } from "@casl/ability";
import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Modal, ModalContent, Tooltip } from "@app/components/v2";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { useTimedReset } from "@app/hooks";
import { useGetPkiSubscriber, useIssuePkiSubscriberCert } from "@app/hooks/api";
import { pkiSubscriberStatusToNameMap } from "@app/hooks/api/pkiSubscriber/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertificateContent } from "../../CertificatesPage/components/CertificateContent";
type Props = {
subscriberName: string;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["pkiSubscriber"]>, data?: object) => void;
};
type TCertificateDetails = {
serialNumber: string;
certificate: string;
certificateChain: string;
privateKey: string;
};
export const PkiSubscriberDetailsSection = ({ subscriberName, handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace.id;
const { permission } = useProjectPermission();
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { data: pkiSubscriber } = useGetPkiSubscriber({
subscriberName,
projectId
});
const { mutateAsync: issuePkiSubscriberCert, isPending: isIssuingCert } =
useIssuePkiSubscriberCert();
const onIssuePkiSubscriberCert = async () => {
try {
const response = await issuePkiSubscriberCert({ subscriberName, projectId });
setCertificateDetails({
serialNumber: response.serialNumber,
certificate: response.certificate,
certificateChain: response.certificateChain,
privateKey: response.privateKey
});
setIsModalOpen(true);
createNotification({
text: "Successfully issued certificate",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to issue certificate",
type: "error"
});
}
};
const canIssuePkiSubscriberCert = permission.can(
ProjectPermissionPkiSubscriberActions.IssueCert,
subject(ProjectPermissionSub.PkiSubscribers, {
name: pkiSubscriber?.name ?? ""
})
);
return pkiSubscriber ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">PKI Subscriber Details</h3>
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Edit}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => {
return (
<Tooltip content="Edit PKI Subscriber">
<IconButton
isDisabled={!isAllowed}
ariaLabel="edit icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("pkiSubscriber", {
subscriberName: pkiSubscriber.name
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</ProjectPermissionCan>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">PKI Subscriber ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{pkiSubscriber.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(pkiSubscriber.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{pkiSubscriber.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Status</p>
<p className="text-sm text-mineshaft-300">
{pkiSubscriberStatusToNameMap[pkiSubscriber.status]}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Common Name</p>
<p className="text-sm text-mineshaft-300">{pkiSubscriber.commonName}</p>
</div>
{canIssuePkiSubscriberCert && (
<Button
className="mt-4 w-full"
colorSchema="primary"
type="button"
isLoading={isIssuingCert}
onClick={() => {
onIssuePkiSubscriberCert();
}}
>
Issue Certificate
</Button>
)}
</div>
<Modal
isOpen={isModalOpen}
onOpenChange={(isOpen) => {
setIsModalOpen(isOpen);
if (!isOpen) {
setCertificateDetails(null);
}
}}
>
<ModalContent title="Certificate Details">
{certificateDetails && (
<CertificateContent
serialNumber={certificateDetails.serialNumber}
certificate={certificateDetails.certificate}
certificateChain={certificateDetails.certificateChain}
privateKey={certificateDetails.privateKey}
/>
)}
</ModalContent>
</Modal>
</div>
) : (
<div />
);
};
@@ -0,0 +1,2 @@
export { PkiSubscriberCertificatesSection } from "./PkiSubscriberCertificatesSection";
export { PkiSubscriberDetailsSection } from "./PkiSubscriberDetailsSection";
@@ -0,0 +1,25 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { PkiSubscriberDetailsByIDPage } from "./PkiSubscriberDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers/$subscriberName"
)({
component: PkiSubscriberDetailsByIDPage,
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Subscribers",
link: linkOptions({
to: "/cert-manager/$projectId/subscribers",
params: {
projectId: params.projectId
}
})
}
]
};
}
});
@@ -0,0 +1,28 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { PkiSubscriberSection } from "./components";
export const PkiSubscribersPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<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">
<PageHeader
title="Subscribers"
description="Manage subscribers that request and receive certificates, including user devices, servers, and services."
/>
<PkiSubscriberSection />
</div>
</div>
</div>
</>
);
};
@@ -0,0 +1,428 @@
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 {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Checkbox,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
CaStatus,
useCreatePkiSubscriber,
useGetPkiSubscriber,
useListWorkspaceCas,
useListWorkspacePkiSubscribers,
useUpdatePkiSubscriber
} from "@app/hooks/api";
import {
EXTENDED_KEY_USAGES_OPTIONS,
KEY_USAGES_OPTIONS
} from "@app/hooks/api/certificates/constants";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["pkiSubscriber"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["pkiSubscriber"]>, state?: boolean) => void;
};
const schema = z
.object({
name: z.string().trim().min(1, "Name is required"),
caId: z.string().min(1, "Issuing CA is required"),
commonName: z.string().trim().min(1, "Common Name is required"),
subjectAlternativeNames: z.string(),
ttl: z.string().trim(),
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()
}),
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()
})
})
.required();
export type FormData = z.infer<typeof schema>;
export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace.id;
const { data: subscribers } = useListWorkspacePkiSubscribers(projectId);
const { data: cas } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? "",
status: CaStatus.ACTIVE
});
const { data: pkiSubscriber } = useGetPkiSubscriber({
subscriberName:
(popUp?.pkiSubscriber?.data as { subscriberName: string })?.subscriberName || "",
projectId
});
const { mutateAsync: createMutateAsync } = useCreatePkiSubscriber();
const { mutateAsync: updateMutateAsync } = useUpdatePkiSubscriber();
const {
control,
handleSubmit,
reset,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
caId: "",
commonName: "",
subjectAlternativeNames: "",
ttl: "",
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
}
});
useEffect(() => {
if (pkiSubscriber) {
reset({
name: pkiSubscriber.name,
caId: pkiSubscriber.caId || "",
commonName: pkiSubscriber.commonName,
subjectAlternativeNames: pkiSubscriber.subjectAlternativeNames.join(", ") || "",
ttl: pkiSubscriber.ttl || "",
keyUsages: Object.fromEntries((pkiSubscriber.keyUsages || []).map((name) => [name, true])),
extendedKeyUsages: Object.fromEntries(
(pkiSubscriber.extendedKeyUsages || []).map((name) => [name, true])
)
});
} else {
reset({
name: "",
caId: "",
commonName: "",
subjectAlternativeNames: "",
ttl: "",
keyUsages: {
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
[CertKeyUsage.KEY_ENCIPHERMENT]: true
},
extendedKeyUsages: {}
});
}
}, [pkiSubscriber, reset]);
useEffect(() => {
if (cas?.length) {
setValue("caId", cas[0].id);
}
}, [cas, setValue]);
const onFormSubmit = async ({
name,
caId,
commonName,
subjectAlternativeNames,
ttl,
keyUsages,
extendedKeyUsages
}: FormData) => {
try {
if (!projectId) return;
if (!caId) {
createNotification({
text: "Please select an Issuing CA",
type: "error"
});
return;
}
// Check if there is already a different subscriber with the same name
const existingNames =
subscribers?.filter((s) => s.id !== pkiSubscriber?.id).map((s) => s.name) || [];
if (existingNames.includes(name.trim())) {
createNotification({
text: "A subscriber with this name already exists.",
type: "error"
});
return;
}
const keyUsagesList = Object.entries(keyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertKeyUsage);
const extendedKeyUsagesList = Object.entries(extendedKeyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertExtendedKeyUsage);
const subjectAlternativeNamesList = subjectAlternativeNames
.split(",")
.map((san) => san.trim())
.filter(Boolean);
if (pkiSubscriber) {
await updateMutateAsync({
subscriberName: pkiSubscriber.name,
projectId,
name,
caId,
commonName,
subjectAlternativeNames: subjectAlternativeNamesList,
ttl,
keyUsages: keyUsagesList,
extendedKeyUsages: extendedKeyUsagesList
});
} else {
await createMutateAsync({
projectId,
name,
caId,
commonName,
subjectAlternativeNames: subjectAlternativeNamesList,
ttl,
keyUsages: keyUsagesList,
extendedKeyUsages: extendedKeyUsagesList
});
}
reset();
handlePopUpToggle("pkiSubscriber", false);
createNotification({
text: `Successfully ${pkiSubscriber ? "updated" : "added"} PKI subscriber`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${pkiSubscriber ? "update" : "add"} PKI subscriber`,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.pkiSubscriber?.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("pkiSubscriber", isOpen);
}}
>
<ModalContent title={`${pkiSubscriber ? "Update" : "Add"} PKI Subscriber`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{pkiSubscriber && (
<FormControl label="Subscriber ID">
<Input value={pkiSubscriber.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Subscriber Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="web-service" />
</FormControl>
)}
/>
<Controller
control={control}
name="caId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
errorText={error?.message}
isError={Boolean(error)}
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(cas || []).map(({ id, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{dn}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="commonName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Common Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="web.example.com" />
</FormControl>
)}
/>
<Controller
control={control}
name="subjectAlternativeNames"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Subject Alternative Names (SANs)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="app1.example.com, app2.example.com, ..." />
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="2 days, 1d, 2h, 1y, ..." />
</FormControl>
)}
/>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="key-usages" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Key Usage</div>
</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="keyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
{KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
<Controller
control={control}
name="extendedKeyUsages"
render={({ field: { onChange, value }, fieldState: { error } }) => {
return (
<FormControl
label="Extended Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="mb-7 mt-2 grid grid-cols-2 gap-2">
{EXTENDED_KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={optionValue}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</div>
</FormControl>
);
}}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-4 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{pkiSubscriber ? "Update" : "Add"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("pkiSubscriber", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,151 @@
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
useWorkspace
} from "@app/context";
import { useDeletePkiSubscriber, useUpdatePkiSubscriber } from "@app/hooks/api";
import { PkiSubscriberStatus } from "@app/hooks/api/pkiSubscriber/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { PkiSubscriberModal } from "./PkiSubscriberModal";
import { PkiSubscribersTable } from "./PkiSubscribersTable";
export const PkiSubscriberSection = () => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace.id;
const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber();
const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"pkiSubscriber",
"pkiSubscriberStatus", // enable / disable
"deletePkiSubscriber"
] as const);
const onRemovePkiSubscriberSubmit = async (subscriberName: string) => {
try {
const subscriber = await deletePkiSubscriber({ subscriberName, projectId });
createNotification({
text: `Successfully deleted PKI subscriber: ${subscriber.name}`,
type: "success"
});
handlePopUpClose("deletePkiSubscriber");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete PKI subscriber",
type: "error"
});
}
};
const onUpdatePkiSubscriberStatus = async ({
subscriberName,
status
}: {
subscriberName: string;
status: PkiSubscriberStatus;
}) => {
try {
if (!currentWorkspace?.slug) return;
await updatePkiSubscriber({ subscriberName, projectId, status });
createNotification({
text: `Successfully ${status === PkiSubscriberStatus.ACTIVE ? "enabled" : "disabled"} subscriber`,
type: "success"
});
handlePopUpClose("pkiSubscriberStatus");
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${status === PkiSubscriberStatus.ACTIVE ? "enable" : "disable"} subscriber`,
type: "error"
});
}
};
const subscriberStatusData = popUp?.pkiSubscriberStatus?.data as {
status: PkiSubscriberStatus;
subscriberName: string;
};
const isEnabling = subscriberStatusData?.status === PkiSubscriberStatus.ACTIVE;
const subscriberName = subscriberStatusData?.subscriberName || "";
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Subscribers</p>
<div className="flex w-full justify-end">
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/pki/subscribers"
>
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</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>
</div>
</div>
<PkiSubscribersTable handlePopUpOpen={handlePopUpOpen} />
<PkiSubscriberModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.pkiSubscriberStatus.isOpen}
title={`Are you sure you want to ${isEnabling ? "enable" : "disable"} the subscriber ${subscriberName}?`}
subTitle={
isEnabling
? "This action will allow issuing certificates for this subscriber again."
: "This action will prevent issuing certificates for this subscriber."
}
onChange={(isOpen) => handlePopUpToggle("pkiSubscriberStatus", isOpen)}
deleteKey="confirm"
buttonColorSchema={isEnabling ? "primary" : "danger"}
buttonText={isEnabling ? "Enable" : "Disable"}
onDeleteApproved={() => onUpdatePkiSubscriberStatus(subscriberStatusData)}
/>
<DeleteActionModal
isOpen={popUp.deletePkiSubscriber.isOpen}
title="Are you sure you want to remove the PKI subscriber?"
onChange={(isOpen) => handlePopUpToggle("deletePkiSubscriber", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemovePkiSubscriberSubmit(
(popUp?.deletePkiSubscriber?.data as { subscriberName: string })?.subscriberName
)
}
/>
</div>
);
};
@@ -0,0 +1,188 @@
import {
faBan,
faEllipsis,
faPencil,
faTrash,
faUserShield
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import {
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub,
useWorkspace
} from "@app/context";
import { useListWorkspacePkiSubscribers } from "@app/hooks/api";
import {
getPkiSubscriberStatusBadgeVariant,
PkiSubscriberStatus,
pkiSubscriberStatusToNameMap
} from "@app/hooks/api/pkiSubscriber/constants";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deletePkiSubscriber", "pkiSubscriber", "pkiSubscriberStatus"]>,
data?: object
) => void;
};
export const PkiSubscribersTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useListWorkspacePkiSubscribers(currentWorkspace?.id || "");
return (
<div>
<TableContainer>
<Table className="w-full table-fixed">
<THead>
<Tr>
<Th>Name</Th>
<Th>Status</Th>
<Th>Common Name</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="pki-subscribers" />}
{!isPending &&
data &&
data.length > 0 &&
data.map((subscriber) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`pki-subscriber-${subscriber.id}`}
onClick={() =>
navigate({
to: `/${ProjectType.CertificateManager}/$projectId/subscribers/$subscriberName` as const,
params: {
projectId: currentWorkspace.id,
subscriberName: subscriber.name
}
})
}
>
<Td>{subscriber.name}</Td>
<Td>
<Badge variant={getPkiSubscriberStatusBadgeVariant(subscriber.status)}>
{pkiSubscriberStatusToNameMap[subscriber.status]}
</Badge>
</Td>
<Td>{subscriber.commonName}</Td>
<Td className="text-right align-middle">
<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">
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Edit}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("pkiSubscriber", {
subscriberName: subscriber.name
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faPencil} />}
>
Edit Subscriber
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Edit}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("pkiSubscriberStatus", {
subscriberName: subscriber.name,
status:
subscriber.status === PkiSubscriberStatus.ACTIVE
? PkiSubscriberStatus.DISABLED
: PkiSubscriberStatus.ACTIVE
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
{`${subscriber.status === PkiSubscriberStatus.ACTIVE ? "Disable" : "Enable"} Subscriber`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionPkiSubscriberActions.Delete}
a={ProjectPermissionSub.PkiSubscribers}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deletePkiSubscriber", {
subscriberName: subscriber.name
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Subscriber
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data?.length === 0 && (
<EmptyState title="No PKI subscribers have been added" icon={faUserShield} />
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1 @@
export { PkiSubscriberSection } from "./PkiSubscriberSection";
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { PkiSubscribersPage } from "./PkiSubscribersPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers/"
)({
component: PkiSubscribersPage
});
+1 -1
View File
@@ -31,7 +31,7 @@ export const Route = createFileRoute(
{
label: project.name,
link: linkOptions({
to: "/cert-manager/$projectId/overview",
to: "/cert-manager/$projectId/subscribers",
params: { projectId: project.id }
})
}
@@ -58,6 +58,15 @@ export const OrgAdminProjects = withPermission(
await orgAdminAccessProject.mutateAsync({
projectId
});
if (type === ProjectType.CertificateManager) {
await navigate({
to: "/cert-manager/$projectId/subscribers" as const,
params: {
projectId
}
});
return;
}
await navigate({
to: `/${type}/$projectId/overview` as const,
params: {
@@ -22,6 +22,7 @@ import { HCVaultConnectionForm } from "./HCVaultConnectionForm";
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
import { LdapConnectionForm } from "./LdapConnectionForm";
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
import { OCIConnectionForm } from "./OCIConnectionForm";
import { PostgresConnectionForm } from "./PostgresConnectionForm";
import { TeamCityConnectionForm } from "./TeamCityConnectionForm";
import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm";
@@ -101,6 +102,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <LdapConnectionForm onSubmit={onSubmit} />;
case AppConnection.TeamCity:
return <TeamCityConnectionForm onSubmit={onSubmit} />;
case AppConnection.OCI:
return <OCIConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -173,6 +176,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <LdapConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.TeamCity:
return <TeamCityConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.OCI:
return <OCIConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
@@ -0,0 +1,215 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { OCIConnectionMethod, TOCIConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TOCIConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.OCI)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(OCIConnectionMethod.AccessKey),
credentials: z.object({
userOcid: z
.string()
.trim()
.min(1, "User OCID required")
.regex(/^ocid1\.user\.oc1\.\..+$/, "Invalid User OCID format"),
tenancyOcid: z
.string()
.trim()
.min(1, "Tenancy OCID required")
.regex(/^ocid1\.tenancy\.oc1\.\..+$/, "Invalid Tenancy OCID format"),
region: z.string().trim().min(1, "Region required"),
fingerprint: z.string().trim().min(1, "Fingerprint required"),
privateKey: z.string().trim().min(1, "Private Key required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const OCIConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.OCI,
method: OCIConnectionMethod.AccessKey
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.OCI].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(OCIConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.userOcid"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="User OCID"
tooltipClassName="max-w-sm"
tooltipText="The unique identifier (OCID) associated with your OCI user account. You can find this in your OCI console under Identity > Domains > [domain] > Users > [user]."
>
<Input
{...field}
placeholder="ocid1.user.oc1..************************************************************"
/>
</FormControl>
)}
/>
<Controller
name="credentials.tenancyOcid"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Tenancy OCID"
tooltipClassName="max-w-sm"
tooltipText="The unique identifier (OCID) for your tenancy in Oracle Cloud. You can find this in your OCI console under Administration > Tenancy Details."
>
<Input
{...field}
placeholder="ocid1.tenancy.oc1..************************************************************"
/>
</FormControl>
)}
/>
<Controller
name="credentials.region"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Region"
tooltipClassName="max-w-sm"
tooltipText="The OCI region where your resources are located (e.g., us-ashburn-1, eu-frankfurt-1)."
>
<Input {...field} placeholder="us-ashburn-1" />
</FormControl>
)}
/>
<Controller
name="credentials.fingerprint"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Fingerprint"
tooltipClassName="max-w-sm"
tooltipText="The fingerprint of the public key associated with your OCI API key. This is generated when you create an API key in your OCI user settings."
>
<Input {...field} placeholder="00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00" />
</FormControl>
)}
/>
<Controller
name="credentials.privateKey"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Private Key PEM"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to OCI"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -1 +0,0 @@
export { OrgAuthTab } from "./OrgAuthTab";
@@ -4,8 +4,8 @@ import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
import { useOrganization, useOrgPermission } from "@app/context";
import { useDeleteOrgById } from "@app/hooks/api";
import { clearSession } from "@app/hooks/api/users/queries";
import { usePopUp } from "@app/hooks/usePopUp";
import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils";
export const OrgDeleteSection = () => {
const navigate = useNavigate();
@@ -13,9 +13,7 @@ export const OrgDeleteSection = () => {
const { membership } = useOrgPermission();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteOrg"
] as const);
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["deleteOrg"] as const);
const { mutateAsync, isPending } = useDeleteOrgById();
@@ -32,9 +30,8 @@ export const OrgDeleteSection = () => {
type: "success"
});
await navigateUserToOrg(navigate);
handlePopUpClose("deleteOrg");
clearSession();
navigate({ to: "/login" });
} catch (err) {
console.error(err);
createNotification({
@@ -0,0 +1,35 @@
import { Link } from "@tanstack/react-router";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { OrgGenericAuthSection } from "./OrgGenericAuthSection";
import { OrgUserAccessTokenLimitSection } from "./OrgUserAccessTokenLimitSection";
export const OrgSecurityTab = withPermission(
() => {
return (
<>
<NoticeBannerV2
className="mx-auto mb-4"
titleClassName="text-base"
title="Single Sign-On (SSO) Settings"
>
<p className="mt-1 text-mineshaft-300">
SSO Settings have been relocated:{" "}
<Link
className="text-mineshaft-200 underline underline-offset-2"
to="/organization/sso"
>
Click here to view SSO Settings
</Link>
</p>
</NoticeBannerV2>
<OrgGenericAuthSection />
<OrgUserAccessTokenLimitSection />
</>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Sso }
);
@@ -86,13 +86,12 @@ export const OrgUserAccessTokenLimitSection = () => {
];
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">User Token Expiration</p>
<p className="text-xl font-semibold">Session Length</p>
</div>
<p className="mb-4 mt-2 text-sm text-gray-400">
This defines the maximum time a user token will be valid. After this time, the user will
need to re-authenticate.
Specify the duration of each login session for users in this organization.
</p>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
@@ -0,0 +1 @@
export * from "./OrgSecurityTab";
@@ -10,9 +10,9 @@ import { ProjectType } from "@app/hooks/api/workspace/types";
import { AuditLogStreamsTab } from "../AuditLogStreamTab";
import { ImportTab } from "../ImportTab";
import { KmipTab } from "../KmipTab/OrgKmipTab";
import { OrgAuthTab } from "../OrgAuthTab";
import { OrgEncryptionTab } from "../OrgEncryptionTab";
import { OrgGeneralTab } from "../OrgGeneralTab";
import { OrgSecurityTab } from "../OrgSecurityTab";
import { OrgWorkflowIntegrationTab } from "../OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab";
export const OrgTabGroup = () => {
@@ -21,7 +21,7 @@ export const OrgTabGroup = () => {
});
const tabs = [
{ name: "General", key: "tab-org-general", component: OrgGeneralTab },
{ name: "Security", key: "tab-org-security", component: OrgAuthTab },
{ name: "Security", key: "tab-org-security", component: OrgSecurityTab },
{ name: "Encryption", key: "tab-org-encryption", component: OrgEncryptionTab },
{
name: "Workflow Integrations",
@@ -1,37 +1,33 @@
import { faHome } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
createFileRoute,
linkOptions,
stripSearchParams,
} from '@tanstack/react-router'
import { zodValidator } from '@tanstack/zod-adapter'
import { z } from 'zod'
import { faHome } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SettingsPage } from './SettingsPage'
import { SettingsPage } from "./SettingsPage";
const SettingsPageQueryParams = z.object({
selectedTab: z.string().catch(''),
})
selectedTab: z.string().catch("")
});
export const Route = createFileRoute(
'/_authenticate/_inject-org-details/_org-layout/organization/settings/',
"/_authenticate/_inject-org-details/_org-layout/organization/settings/"
)({
component: SettingsPage,
validateSearch: zodValidator(SettingsPageQueryParams),
search: {
middlewares: [stripSearchParams({ selectedTab: '' })],
middlewares: [stripSearchParams({ selectedTab: "" })]
},
context: () => ({
breadcrumbs: [
{
label: 'Home',
label: "Home",
icon: () => <FontAwesomeIcon icon={faHome} />,
link: linkOptions({ to: '/' }),
link: linkOptions({ to: "/" })
},
{
label: 'Settings',
},
],
}),
})
label: "Settings"
}
]
})
});
@@ -0,0 +1,21 @@
import { Helmet } from "react-helmet";
import { PageHeader } from "@app/components/v2";
import { SsoTabGroup } from "./components/SsoTabGroup";
export const SsoPage = () => {
return (
<>
<Helmet>
<title>Single Sign-On (SSO)</title>
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<PageHeader title="Single Sign-On (SSO)" />
<SsoTabGroup />
</div>
</div>
</>
);
};
@@ -0,0 +1,17 @@
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { OrgGithubSyncSection } from "./OrgGithubSyncSection";
import { OrgScimSection } from "./OrgSCIMSection";
export const OrgProvisioningTab = withPermission(
() => {
return (
<>
<OrgScimSection />
<OrgGithubSyncSection />
</>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Sso }
);
@@ -0,0 +1 @@
export * from "./OrgProvisioningTab";
@@ -17,16 +17,12 @@ import { LoginMethod } from "@app/hooks/api/admin/types";
import { LDAPModal } from "./LDAPModal";
import { OIDCModal } from "./OIDCModal";
import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection";
import { OrgGenericAuthSection } from "./OrgGenericAuthSection";
import { OrgGithubSyncSection } from "./OrgGithubSyncSection";
import { OrgLDAPSection } from "./OrgLDAPSection";
import { OrgOIDCSection } from "./OrgOIDCSection";
import { OrgScimSection } from "./OrgSCIMSection";
import { OrgSSOSection } from "./OrgSSOSection";
import { OrgUserAccessTokenLimitSection } from "./OrgUserAccessTokenLimitSection";
import { SSOModal } from "./SSOModal";
export const OrgAuthTab = withPermission(
export const OrgSsoTab = withPermission(
() => {
const {
config: { enabledLoginMethods }
@@ -167,8 +163,6 @@ export const OrgAuthTab = withPermission(
return (
<>
<OrgGenericAuthSection />
<OrgUserAccessTokenLimitSection />
{shouldShowCreateIdentityProviderView ? (
createIdentityProviderView
) : (
@@ -183,8 +177,6 @@ export const OrgAuthTab = withPermission(
{isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && <OrgLDAPSection />}
</>
)}
<OrgScimSection />
<OrgGithubSyncSection />
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
@@ -0,0 +1 @@
export { OrgSsoTab } from "./OrgSsoTab";
@@ -0,0 +1,37 @@
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 { OrgProvisioningTab } from "../OrgProvisioningTab";
import { OrgSsoTab } from "../OrgSsoTab";
export const SsoTabGroup = () => {
const search = useSearch({
from: ROUTE_PATHS.Organization.SsoPage.id
});
const tabs = [
{ name: "General", key: "tab-sso-auth", component: OrgSsoTab },
{ name: "Provisioning", key: "tab-sso-identity", component: OrgProvisioningTab }
];
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>
);
};
@@ -0,0 +1 @@
export { SsoTabGroup } from "./SsoTabGroup";
@@ -0,0 +1,33 @@
import { faHome } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SsoPage } from "./SsoPage";
const SettingsPageQueryParams = z.object({
selectedTab: z.string().catch("")
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/sso"
)({
component: SsoPage,
validateSearch: zodValidator(SettingsPageQueryParams),
search: {
middlewares: [stripSearchParams({ selectedTab: "" })]
},
context: () => ({
breadcrumbs: [
{
label: "Home",
icon: () => <FontAwesomeIcon icon={faHome} />,
link: linkOptions({ to: "/" })
},
{
label: "Single Sign-On (SSO)"
}
]
})
});
@@ -1,12 +1,6 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import {
faCaretDown,
faChevronLeft,
faClock,
faPlus,
faSave
} from "@fortawesome/free-solid-svg-icons";
import { faCaretDown, faChevronLeft, faClock, faSave } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
@@ -33,16 +27,15 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useCreateIdentityProjectAdditionalPrivilege,
useGetIdentityProjectPrivilegeDetails,
useUpdateIdentityProjectAdditionalPrivilege
} from "@app/hooks/api";
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types";
import { AddPoliciesButton } from "@app/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton";
import { GeneralPermissionPolicies } from "@app/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies";
import { PermissionEmptyState } from "@app/pages/project/RoleDetailsBySlugPage/components/PermissionEmptyState";
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
import {
formRolePermission2API,
PROJECT_PERMISSION_OBJECT,
@@ -97,7 +90,6 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId })
);
const { popUp, handlePopUpToggle } = usePopUp(["addPolicy"] as const);
const form = useForm<TFormSchema>({
values: privilegeDetails
@@ -224,7 +216,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
variant="outline_bg"
type="submit"
className={twMerge(
"h-10 rounded-r-none border border-primary",
"mr-4 h-10 border border-primary",
isDirty && "bg-primary text-black"
)}
isDisabled={isSubmitting || !isDirty || isDisabled}
@@ -233,15 +225,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
>
Save
</Button>
<Button
isDisabled={isDisabled}
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpToggle("addPolicy")}
>
Add Policies
</Button>
<AddPoliciesButton isDisabled={isDisabled} />
</div>
</div>
</div>
@@ -382,10 +366,6 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
)
)}
</div>
<PolicySelectionModal
isOpen={popUp.addPolicy.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addPolicy", isOpen)}
/>
</FormProvider>
</form>
);
@@ -1,11 +1,5 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import {
faCaretDown,
faChevronLeft,
faClock,
faPlus,
faSave
} from "@fortawesome/free-solid-svg-icons";
import { faCaretDown, faChevronLeft, faClock, faSave } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
@@ -32,16 +26,15 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useCreateProjectUserAdditionalPrivilege,
useGetProjectUserPrivilegeDetails,
useUpdateProjectUserAdditionalPrivilege
} from "@app/hooks/api";
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types";
import { AddPoliciesButton } from "@app/pages/project/RoleDetailsBySlugPage/components/AddPoliciesButton";
import { GeneralPermissionPolicies } from "@app/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies";
import { PermissionEmptyState } from "@app/pages/project/RoleDetailsBySlugPage/components/PermissionEmptyState";
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
import {
formRolePermission2API,
PROJECT_PERMISSION_OBJECT,
@@ -83,8 +76,6 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
projectMembershipId,
isDisabled
}: Props) => {
const { popUp, handlePopUpToggle } = usePopUp(["addPolicy"] as const);
const isCreate = !privilegeId;
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
@@ -221,7 +212,7 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
variant="outline_bg"
type="submit"
className={twMerge(
"h-10 rounded-r-none border border-primary",
"mr-4 h-10 border border-primary",
isDirty && "bg-primary text-black"
)}
isDisabled={isSubmitting || !isDirty || isDisabled}
@@ -230,15 +221,7 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
>
Save
</Button>
<Button
isDisabled={isDisabled}
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpToggle("addPolicy")}
>
Add Policies
</Button>
<AddPoliciesButton isDisabled={isDisabled} />
</div>
</div>
</div>
@@ -377,10 +360,6 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
</GeneralPermissionPolicies>
))}
</div>
<PolicySelectionModal
isOpen={popUp.addPolicy.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addPolicy", isOpen)}
/>
</FormProvider>
</form>
);
@@ -0,0 +1,78 @@
import { faAngleDown, faLayerGroup, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
IconButton
} from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
import { PolicyTemplateModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicyTemplateModal";
type Props = {
isDisabled?: boolean;
};
export const AddPoliciesButton = ({ isDisabled }: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addPolicy",
"addPolicyOptions",
"applyTemplate"
] as const);
return (
<>
<Button
className="h-10 rounded-r-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={isDisabled}
onClick={() => handlePopUpToggle("addPolicy")}
>
Add Policies
</Button>
<DropdownMenu
open={popUp.addPolicyOptions.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addPolicyOptions", isOpen)}
>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Open policy template options"
variant="outline_bg"
className="rounded-l-none bg-mineshaft-600 p-3"
>
<FontAwesomeIcon icon={faAngleDown} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<div className="flex flex-col space-y-1 p-1.5">
<Button
leftIcon={<FontAwesomeIcon icon={faLayerGroup} className="pr-2" />}
onClick={() => {
handlePopUpOpen("applyTemplate");
handlePopUpClose("addPolicyOptions");
}}
isDisabled={isDisabled}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add From Template
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
<PolicySelectionModal
isOpen={popUp.addPolicy.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addPolicy", isOpen)}
/>
<PolicyTemplateModal
isOpen={popUp.applyTemplate.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("applyTemplate", isOpen)}
/>
</>
);
};
@@ -1,5 +1,5 @@
import { cloneElement, useState } from "react";
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { cloneElement, ReactNode, useState } from "react";
import { Control, Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form";
import {
faChevronDown,
faChevronRight,
@@ -29,6 +29,43 @@ type Props<T extends ProjectPermissionSub> = {
isDisabled?: boolean;
};
type ActionProps = {
value: string;
subject: ProjectPermissionSub;
rootIndex: number;
label: ReactNode;
isDisabled?: boolean;
control: Control<TFormSchema>;
};
const ActionCheckbox = ({ value, subject, isDisabled, rootIndex, label, control }: ActionProps) => {
// scott: using Controller caused discrepancy between field value and actual value, this is a hacky fix
const fieldValue = useWatch({
control,
name: `permissions.${subject}.${rootIndex}.${value}` as any
});
const { setValue } = useFormContext();
return (
<div className="flex items-center justify-center">
<Checkbox
isDisabled={isDisabled}
isChecked={Boolean(fieldValue)}
onCheckedChange={(isChecked) =>
setValue(`permissions.${subject}.${rootIndex}.${value}`, isChecked, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
})
}
id={`permissions.${subject}.${rootIndex}.${String(value)}`}
>
{label}
</Checkbox>
</div>
);
};
export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchema["permissions"]>>({
subject,
actions,
@@ -41,11 +78,18 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
control,
name: `permissions.${subject}`
});
// scott: this is a hacky work-around to resolve bug of fields not updating UI when removed
const watchFields = useWatch<TFormSchema>({
control,
name: `permissions.${subject}`
});
const [isOpen, setIsOpen] = useToggle();
const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
if (!fields.length) return <div />;
if (!watchFields || !Array.isArray(watchFields) || watchFields.length === 0) return <div />;
const handleDragStart = (_: React.DragEvent, index: number) => {
setDraggedItem(index);
@@ -194,25 +238,14 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
}
return (
<Controller
<ActionCheckbox
key={`${el.id}-${index + 1}`}
name={`permissions.${subject}.${rootIndex}.${value}` as any}
value={value}
label={label}
rootIndex={rootIndex}
control={control}
defaultValue={false}
render={({ field }) => {
return (
<div className="flex items-center justify-center">
<Checkbox
isDisabled={isDisabled}
isChecked={Boolean(field.value)}
onCheckedChange={field.onChange}
id={`permissions.${subject}.${rootIndex}.${String(value)}`}
>
{label}
</Checkbox>
</div>
);
}}
subject={subject}
isDisabled={isDisabled}
/>
);
})}
@@ -0,0 +1,173 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import {
PermissionConditionOperators,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { getConditionOperatorHelperInfo } from "./PermissionConditionHelpers";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
position?: number;
isDisabled?: boolean;
};
export const PkiSubscriberPermissionConditions = ({ position = 0, isDisabled }: Props) => {
const {
control,
watch,
formState: { errors }
} = useFormContext<TFormSchema>();
const permissionSubject = ProjectPermissionSub.PkiSubscribers;
const items = useFieldArray({
control,
name: `permissions.${permissionSubject}.${position}.conditions`
});
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="text-sm text-mineshaft-400">
Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
const condition =
(watch(`permissions.${permissionSubject}.${position}.conditions.${index}`) as {
lhs: string;
rhs: string;
operator: string;
}) || {};
return (
<div
key={el.id}
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
>
<div className="w-1/4">
<Controller
control={control}
name={`permissions.${permissionSubject}.${position}.conditions.${index}.lhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
<SelectItem value="name">Name</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex w-36 items-center space-x-2">
<Controller
control={control}
name={`permissions.${permissionSubject}.${position}.conditions.${index}.operator`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
<SelectItem value={PermissionConditionOperators.$EQ}>Equals</SelectItem>
<SelectItem value={PermissionConditionOperators.$GLOB}>Glob</SelectItem>
<SelectItem value={PermissionConditionOperators.$IN}>In</SelectItem>
</Select>
</FormControl>
)}
/>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
<div className="flex-grow">
<Controller
control={control}
name={`permissions.${permissionSubject}.${position}.conditions.${index}.rhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<IconButton
ariaLabel="plus"
variant="outline_bg"
className="p-2.5"
onClick={() => items.remove(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
})}
</div>
{errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message}</span>
</div>
)}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-3"
isDisabled={isDisabled}
onClick={() =>
items.append({
lhs: "name",
operator: PermissionConditionOperators.$EQ,
rhs: ""
})
}
>
Add Condition
</Button>
</div>
</div>
);
};
@@ -0,0 +1,202 @@
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Modal,
ModalClose,
ModalContent
} from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { useGetProjectTypeFromRoute } from "@app/hooks";
import { ProjectType } from "@app/hooks/api/workspace/types";
import {
PROJECT_PERMISSION_OBJECT,
RoleTemplate,
RoleTemplates,
TFormSchema
} from "./ProjectRoleModifySection.utils";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onClose: () => void;
};
const Content = ({ onClose }: ContentProps) => {
const rootForm = useFormContext<TFormSchema>();
const projectType = useGetProjectTypeFromRoute();
const [selectedTemplate, setSelectedTemplate] = useState<RoleTemplate>();
const [conflictingSubjects, setConflictingSubjects] = useState<ProjectPermissionSub[]>([]);
const [showConflictingSubjects, setShowConflictingSubjects] = useState(false);
const templates = RoleTemplates[projectType ?? ProjectType.SecretManager];
const onSubmit = (skipConflicting = false) => {
if (!selectedTemplate) {
createNotification({ type: "error", text: "Please select a template" });
return;
}
selectedTemplate.permissions.forEach(({ subject, actions }) => {
if (skipConflicting && conflictingSubjects.includes(subject)) return;
rootForm.setValue(
`permissions.${subject}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
[Object.fromEntries(actions.map((action) => [action, true]))],
{
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
}
);
});
onClose();
};
const onApply = () => {
if (!selectedTemplate) {
createNotification({ type: "error", text: "Please select a template" });
return;
}
const conflictingPolicies: ProjectPermissionSub[] = [];
selectedTemplate.permissions.forEach(({ subject }) => {
const rootPolicyValue = rootForm.getValues("permissions")?.[subject];
if (rootPolicyValue?.length) {
conflictingPolicies.push(subject);
}
});
if (conflictingPolicies.length) {
setConflictingSubjects(conflictingPolicies);
setShowConflictingSubjects(true);
return;
}
onSubmit();
};
return (
<>
<Modal isOpen={showConflictingSubjects} onOpenChange={setShowConflictingSubjects}>
<ModalContent
title="Conflicting Policies"
subTitle="The following resources already have policies assigned to them."
>
<div className="grid grid-cols-2 gap-2 text-sm">
{conflictingSubjects.map((subject) => (
<div key={subject}>
<span className="text-mineshaft-200">
{PROJECT_PERMISSION_OBJECT[subject].title}
</span>
</div>
))}
</div>
<div className="mt-8 flex space-x-4">
<ModalClose asChild>
<Button colorSchema="danger" onClick={() => onSubmit()}>
Overwrite Existing
</Button>
</ModalClose>
<ModalClose asChild>
<Button colorSchema="secondary" onClick={() => onSubmit(true)}>
Skip Conflicting
</Button>
</ModalClose>
</div>
</ModalContent>
</Modal>
<Accordion
type="single"
value={selectedTemplate?.id}
onValueChange={(value) =>
setSelectedTemplate(templates.find((template) => template.id === value))
}
collapsible
className="w-full border-collapse"
>
{templates.map(({ name, description, permissions, id }) => (
<AccordionItem
key={id}
value={id}
className="m-0 border border-mineshaft-600 first:rounded-t last:rounded-b data-[state=open]:border-primary/40 data-[state=open]:bg-mineshaft-600/30"
>
<AccordionTrigger className="w-full justify-start p-4 py-8 text-mineshaft-100 hover:bg-mineshaft-700 hover:text-mineshaft-100 data-[state=open]:bg-primary/[3%] data-[state=open]:text-mineshaft-100">
<div className="mr-auto flex flex-col py-2 text-left">
<span>{name}</span>
<span className="text-sm leading-3 text-mineshaft-400">{description}</span>
</div>
</AccordionTrigger>
<AccordionContent className="border-t border-mineshaft-600">
<div className="thin-scrollbar max-h-[20rem] overflow-y-auto">
<span className="text-mineshaft-300">Grants the following permissions:</span>
<div className="grid grid-cols-2 gap-4 py-2">
{permissions
.map((permission) => ({
...permission,
object: PROJECT_PERMISSION_OBJECT[permission.subject]
}))
.sort((a, b) => a.object.title.localeCompare(b.object.title))
.map(({ subject, actions, object }) => {
return (
<div key={subject}>
<span className="text-mineshaft-200">{object.title}</span>
<ul className="mt-1 flex list-disc flex-col gap-1 pl-4">
{actions.map((action) => (
<li key={action}>
{object.actions.find((a) => a.value === action)?.label}
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<div className="mt-8 flex space-x-4">
<Button isDisabled={!selectedTemplate} onClick={onApply}>
Apply Template
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</>
);
};
export const PolicyTemplateModal = ({ isOpen, onOpenChange }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Policy Templates"
subTitle="Select a template with prepopulated policies to get started. You can always add more policies later."
className="max-w-3xl"
>
<Content onClose={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -17,6 +17,7 @@ import {
ProjectPermissionIdentityActions,
ProjectPermissionKmipActions,
ProjectPermissionMemberActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions,
ProjectPermissionSecretSyncActions,
@@ -131,6 +132,15 @@ const SshHostPolicyActionSchema = z.object({
[ProjectPermissionSshHostActions.IssueHostCert]: z.boolean().optional()
});
const PkiSubscriberPolicyActionSchema = z.object({
[ProjectPermissionPkiSubscriberActions.Read]: z.boolean().optional(),
[ProjectPermissionPkiSubscriberActions.Create]: z.boolean().optional(),
[ProjectPermissionPkiSubscriberActions.Edit]: z.boolean().optional(),
[ProjectPermissionPkiSubscriberActions.Delete]: z.boolean().optional(),
[ProjectPermissionPkiSubscriberActions.IssueCert]: z.boolean().optional(),
[ProjectPermissionPkiSubscriberActions.ListCerts]: z.boolean().optional()
});
const SecretRollbackPolicyActionSchema = z.object({
read: z.boolean().optional(),
create: z.boolean().optional()
@@ -180,7 +190,7 @@ const ConditionSchema = z
export const projectRoleFormSchema = z.object({
name: z.string().trim(),
description: z.string().trim().optional(),
description: z.string().trim().nullish(),
slug: z
.string()
.trim()
@@ -230,6 +240,12 @@ export const projectRoleFormSchema = z.object({
[ProjectPermissionSub.IpAllowList]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.CertificateAuthorities]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Certificates]: CertificatePolicyActionSchema.array().default([]),
[ProjectPermissionSub.PkiSubscribers]: PkiSubscriberPolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.PkiAlerts]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.PkiCollections]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.CertificateTemplates]: GeneralPolicyActionSchema.array().default([]),
@@ -271,6 +287,7 @@ type TConditionalFields =
| ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports
| ProjectPermissionSub.DynamicSecrets
| ProjectPermissionSub.PkiSubscribers
| ProjectPermissionSub.SshHosts
| ProjectPermissionSub.SecretRotation
| ProjectPermissionSub.Identity;
@@ -284,7 +301,8 @@ export const isConditionalSubjects = (
subject === ProjectPermissionSub.SecretFolders ||
subject === ProjectPermissionSub.Identity ||
subject === ProjectPermissionSub.SshHosts ||
subject === ProjectPermissionSub.SecretRotation;
subject === ProjectPermissionSub.SecretRotation ||
subject === ProjectPermissionSub.PkiSubscribers;
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
const formConditions: z.infer<typeof ConditionSchema> = [];
@@ -715,6 +733,33 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
inverted
});
}
if (subject === ProjectPermissionSub.PkiSubscribers) {
if (!formVal[subject]) formVal[subject] = [];
formVal[subject]!.push({
[ProjectPermissionPkiSubscriberActions.Edit]: action.includes(
ProjectPermissionPkiSubscriberActions.Edit
),
[ProjectPermissionPkiSubscriberActions.Delete]: action.includes(
ProjectPermissionPkiSubscriberActions.Delete
),
[ProjectPermissionPkiSubscriberActions.Create]: action.includes(
ProjectPermissionPkiSubscriberActions.Create
),
[ProjectPermissionPkiSubscriberActions.Read]: action.includes(
ProjectPermissionPkiSubscriberActions.Read
),
[ProjectPermissionPkiSubscriberActions.IssueCert]: action.includes(
ProjectPermissionPkiSubscriberActions.IssueCert
),
[ProjectPermissionPkiSubscriberActions.ListCerts]: action.includes(
ProjectPermissionPkiSubscriberActions.ListCerts
),
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
});
}
});
return formVal;
@@ -877,19 +922,19 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
title: "Dynamic Secrets",
actions: [
{
label: "Read root credentials",
label: "Read Root Credentials",
value: ProjectPermissionDynamicSecretActions.ReadRootCredential
},
{
label: "Create root credentials",
label: "Create Root Credentials",
value: ProjectPermissionDynamicSecretActions.CreateRootCredential
},
{
label: "Modify root credentials",
label: "Modify Root Credentials",
value: ProjectPermissionDynamicSecretActions.EditRootCredential
},
{
label: "Remove root credentials",
label: "Remove Root Credentials",
value: ProjectPermissionDynamicSecretActions.DeleteRootCredential
},
{ label: "Manage Leases", value: ProjectPermissionDynamicSecretActions.Lease }
@@ -1104,6 +1149,17 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.PkiSubscribers]: {
title: "PKI Subscribers",
actions: [
{ label: "Read", value: ProjectPermissionPkiSubscriberActions.Read },
{ label: "Create", value: ProjectPermissionPkiSubscriberActions.Create },
{ label: "Modify", value: ProjectPermissionPkiSubscriberActions.Edit },
{ label: "Remove", value: ProjectPermissionPkiSubscriberActions.Delete },
{ label: "Issue Certificate", value: ProjectPermissionPkiSubscriberActions.IssueCert },
{ label: "List Certificates", value: ProjectPermissionPkiSubscriberActions.ListCerts }
]
},
[ProjectPermissionSub.PkiCollections]: {
title: "PKI Collections",
actions: [
@@ -1174,23 +1230,23 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
title: "KMIP",
actions: [
{
label: "Read clients",
label: "Read Clients",
value: ProjectPermissionKmipActions.ReadClients
},
{
label: "Create clients",
label: "Create Clients",
value: ProjectPermissionKmipActions.CreateClients
},
{
label: "Modify clients",
label: "Modify Clients",
value: ProjectPermissionKmipActions.UpdateClients
},
{
label: "Delete clients",
label: "Delete Clients",
value: ProjectPermissionKmipActions.DeleteClients
},
{
label: "Generate client certificates",
label: "Generate Client Certificates",
value: ProjectPermissionKmipActions.GenerateClientCertificates
}
]
@@ -1233,6 +1289,7 @@ const KmsPermissionSubjects = (enabled = false) => ({
const CertificateManagerPermissionSubjects = (enabled = false) => ({
[ProjectPermissionSub.PkiCollections]: enabled,
[ProjectPermissionSub.PkiAlerts]: enabled,
[ProjectPermissionSub.PkiSubscribers]: enabled,
[ProjectPermissionSub.CertificateAuthorities]: enabled,
[ProjectPermissionSub.CertificateTemplates]: enabled,
[ProjectPermissionSub.Certificates]: enabled
@@ -1280,3 +1337,340 @@ export const ProjectTypePermissionSubjects: Record<
...SecretsManagerPermissionSubjects()
}
};
export type RoleTemplate = {
id: string;
name: string;
description: string;
permissions: { subject: ProjectPermissionSub; actions: string[] }[];
};
const projectManagerTemplate = (
additionalPermissions: RoleTemplate["permissions"] = []
): RoleTemplate => ({
id: "project-manager",
name: "Project Management Policies",
description: "Grants access to manage project members and settings",
permissions: [
{
subject: ProjectPermissionSub.AuditLogs,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.Groups,
actions: Object.values(ProjectPermissionGroupActions)
},
{
subject: ProjectPermissionSub.Member,
actions: Object.values(ProjectPermissionMemberActions)
},
{
subject: ProjectPermissionSub.Identity,
actions: Object.values(ProjectPermissionIdentityActions)
},
{
subject: ProjectPermissionSub.Project,
actions: [ProjectPermissionActions.Edit, ProjectPermissionActions.Delete]
},
{ subject: ProjectPermissionSub.Role, actions: Object.values(ProjectPermissionActions) },
{
subject: ProjectPermissionSub.Settings,
actions: [ProjectPermissionActions.Read, ProjectPermissionActions.Edit]
},
...additionalPermissions
]
});
export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
[ProjectType.SSH]: [
{
id: "ssh-viewer",
name: "SSH Viewing Policies",
description: "Grants read access to SSH certificates and hosts",
permissions: [
{
subject: ProjectPermissionSub.SshCertificateAuthorities,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.SshCertificates,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.SshCertificateTemplates,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.SshHosts,
actions: [ProjectPermissionSshHostActions.Read]
},
{
subject: ProjectPermissionSub.SshHostGroups,
actions: [ProjectPermissionActions.Read]
}
]
},
{
id: "ssh-cert-editor",
name: "SSH Certificate Editing Policies",
description: "Grants read and edit access to SSH certificates",
permissions: [
{
subject: ProjectPermissionSub.SshCertificateAuthorities,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.SshCertificates,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.SshCertificateTemplates,
actions: Object.values(ProjectPermissionActions)
}
]
},
{
id: "ssh-host-editor",
name: "SSH Host Editing Policies",
description: "Grants read and edit access to SSH hosts",
permissions: [
{
subject: ProjectPermissionSub.SshHosts,
actions: Object.values(ProjectPermissionSshHostActions)
},
{
subject: ProjectPermissionSub.SshHostGroups,
actions: Object.values(ProjectPermissionActions)
}
]
},
projectManagerTemplate()
],
[ProjectType.KMS]: [
{
id: "kms-viewer",
name: "KMS Viewing Policies",
description: "Grants read access to KMS keys and KMIP clients",
permissions: [
{
subject: ProjectPermissionSub.Cmek,
actions: [ProjectPermissionCmekActions.Read]
},
{
subject: ProjectPermissionSub.Kmip,
actions: [ProjectPermissionKmipActions.ReadClients]
}
]
},
{
id: "key-editor",
name: "KMS Key Editing Policies",
description: "Grants read and edit access to KMS keys",
permissions: [
{
subject: ProjectPermissionSub.Cmek,
actions: Object.values(ProjectPermissionCmekActions)
}
]
},
{
id: "kmip-editor",
name: "KMIP Client Editing Policies",
description: "Grants read and edit access to KMIP clients",
permissions: [
{
subject: ProjectPermissionSub.Kmip,
actions: Object.values(ProjectPermissionKmipActions)
}
]
},
projectManagerTemplate()
],
[ProjectType.CertificateManager]: [
{
id: "cert-viewer",
name: "Certificate Viewing Policies",
description: "Grants read access to certificates and related resources",
permissions: [
{
subject: ProjectPermissionSub.PkiCollections,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.PkiAlerts,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.CertificateAuthorities,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.CertificateTemplates,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.Certificates,
actions: [
ProjectPermissionCertificateActions.Read,
ProjectPermissionCertificateActions.ReadPrivateKey
]
}
]
},
{
id: "cert-editor",
name: "Certificate Editing Policies",
description: "Grants read and edit access to certificates and related resources",
permissions: [
{
subject: ProjectPermissionSub.PkiCollections,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.PkiAlerts,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.CertificateAuthorities,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.CertificateTemplates,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.Certificates,
actions: Object.values(ProjectPermissionCertificateActions)
}
]
},
projectManagerTemplate()
],
[ProjectType.SecretManager]: [
{
id: "secret-viewer",
name: "Secret Viewing Policies",
description: "Grants read access to secrets and related resources",
permissions: [
{
subject: ProjectPermissionSub.SecretRollback,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.SecretImports,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.Secrets,
actions: [
ProjectPermissionSecretActions.DescribeSecret,
ProjectPermissionSecretActions.ReadValue
]
},
{
subject: ProjectPermissionSub.DynamicSecrets,
actions: [ProjectPermissionDynamicSecretActions.ReadRootCredential]
},
{
subject: ProjectPermissionSub.Environments,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.Tags,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.SecretRotation,
actions: [ProjectPermissionSecretRotationActions.Read]
},
{
subject: ProjectPermissionSub.Integrations,
actions: [ProjectPermissionActions.Read]
},
{
subject: ProjectPermissionSub.SecretSyncs,
actions: [ProjectPermissionSecretSyncActions.Read]
}
]
},
{
id: "secret-editor",
name: "Secret Editing Policies",
description: "Grants read and edit access to secrets and related resources",
permissions: [
{
subject: ProjectPermissionSub.Environments,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.DynamicSecrets,
actions: Object.values(ProjectPermissionDynamicSecretActions)
},
{
subject: ProjectPermissionSub.Secrets,
actions: [
ProjectPermissionSecretActions.DescribeSecret,
ProjectPermissionSecretActions.ReadValue,
ProjectPermissionSecretActions.Edit,
ProjectPermissionSecretActions.Create,
ProjectPermissionSecretActions.Delete
]
},
{
subject: ProjectPermissionSub.SecretRollback,
actions: [ProjectPermissionActions.Read, ProjectPermissionActions.Create]
},
{
subject: ProjectPermissionSub.Tags,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.SecretImports,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.SecretRotation,
actions: Object.values(ProjectPermissionSecretRotationActions)
},
{
subject: ProjectPermissionSub.SecretFolders,
actions: [
ProjectPermissionActions.Create,
ProjectPermissionActions.Edit,
ProjectPermissionActions.Delete
]
},
{
subject: ProjectPermissionSub.Integrations,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.SecretSyncs,
actions: Object.values(ProjectPermissionSecretSyncActions)
}
]
},
projectManagerTemplate([
{
subject: ProjectPermissionSub.IpAllowList,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.Kms,
actions: [ProjectPermissionActions.Edit]
},
{
subject: ProjectPermissionSub.SecretApproval,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.ServiceTokens,
actions: Object.values(ProjectPermissionActions)
},
{
subject: ProjectPermissionSub.Webhooks,
actions: Object.values(ProjectPermissionActions)
}
])
]
};
@@ -62,7 +62,7 @@ export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => {
if (role) {
reset({
name: role.name,
description: role.description,
description: role.description || "",
slug: role.slug
});
} else {
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability";
import { faPlus, faSave } from "@fortawesome/free-solid-svg-icons";
import { faSave } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
@@ -12,17 +12,17 @@ import { Button } from "@app/components/v2";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { evaluatePermissionsAbility } from "@app/helpers/permissions";
import { usePopUp } from "@app/hooks";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
import { AddPoliciesButton } from "./AddPoliciesButton";
import { DynamicSecretPermissionConditions } from "./DynamicSecretPermissionConditions";
import { GeneralPermissionConditions } from "./GeneralPermissionConditions";
import { GeneralPermissionPolicies } from "./GeneralPermissionPolicies";
import { IdentityManagementPermissionConditions } from "./IdentityManagementPermissionConditions";
import { PermissionEmptyState } from "./PermissionEmptyState";
import { PkiSubscriberPermissionConditions } from "./PkiSubscriberPermissionConditions";
import {
formRolePermission2API,
isConditionalSubjects,
@@ -59,6 +59,10 @@ export const renderConditionalComponents = (
return <SshHostPermissionConditions isDisabled={isDisabled} />;
}
if (subject === ProjectPermissionSub.PkiSubscribers) {
return <PkiSubscriberPermissionConditions isDisabled={isDisabled} />;
}
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
}
@@ -86,8 +90,6 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
const { mutateAsync: updateRole } = useUpdateProjectRole();
const { popUp, handlePopUpToggle } = usePopUp(["addPolicy"] as const);
const onSubmit = async (el: TFormSchema) => {
try {
if (!projectId || !role?.id) return;
@@ -151,7 +153,7 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
variant="outline_bg"
type="submit"
className={twMerge(
"h-10 rounded-r-none border border-primary",
"mr-4 h-10 border border-primary",
isDirty && "bg-primary text-black"
)}
isDisabled={isSubmitting || !isDirty}
@@ -160,15 +162,7 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
>
Save
</Button>
<Button
isDisabled={isDisabled}
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpToggle("addPolicy")}
>
Add Policy
</Button>
<AddPoliciesButton isDisabled={isDisabled} />
</div>
</>
)}
@@ -190,10 +184,6 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
</GeneralPermissionPolicies>
))}
</div>
<PolicySelectionModal
isOpen={popUp.addPolicy.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addPolicy", isOpen)}
/>
</FormProvider>
</form>
</div>
@@ -1,11 +1,9 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Badge, PageHeader, Tab, TabList, TabPanel, Tabs, Tooltip } from "@app/components/v2";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
@@ -54,16 +52,7 @@ export const IntegrationsListPage = () => {
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
<TabList>
<Tab value={IntegrationsListPageTabs.SecretSyncs}>Secret Syncs</Tab>
<Tab value={IntegrationsListPageTabs.NativeIntegrations}>
Native Integrations
<Tooltip content="Native Integrations will be deprecated in 2026. Please migrate to Secret Syncs as they become available.">
<div>
<Badge variant="primary" className="ml-1 cursor-pointer text-xs">
Legacy
</Badge>
</div>
</Tooltip>
</Tab>
<Tab value={IntegrationsListPageTabs.NativeIntegrations}>Native Integrations</Tab>
<Tab value={IntegrationsListPageTabs.FrameworkIntegrations}>
Framework Integrations
</Tab>
@@ -81,26 +70,6 @@ export const IntegrationsListPage = () => {
</ProjectPermissionCan>
</TabPanel>
<TabPanel value={IntegrationsListPageTabs.NativeIntegrations}>
<div className="mb-5 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
<div className="mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
Native Integrations Transitioning to Legacy Status
</div>
<p className="mb-2 mt-1 text-sm text-bunker-300">
Native integrations are now a legacy feature and we will begin a phased
deprecation in 2026. We recommend migrating to our new{" "}
<a
className="text-bunker-200 underline decoration-primary-700 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/integrations/secret-syncs/overview"
target="_blank"
rel="noopener noreferrer"
>
Secret Syncs
</a>{" "}
feature which offers the same functionality as Native Integrations with improved
stability, insights, re-configurability, and customization.
</p>
</div>
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionActions.Read}
@@ -0,0 +1,14 @@
import { TOCIVaultSync } from "@app/hooks/api/secretSyncs/types/oci-vault-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TOCIVaultSync;
};
export const OCIVaultSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -10,6 +10,7 @@ import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol";
import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol";
import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol";
import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol";
import { OCIVaultSyncDestinationCol } from "./OCIVaultSyncDestinationCol";
import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol";
import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol";
import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol";
@@ -49,6 +50,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <HCVaultSyncDestinationCol secretSync={secretSync} />;
case SecretSync.TeamCity:
return <TeamCitySyncDestinationCol secretSync={secretSync} />;
case SecretSync.OCIVault:
return <OCIVaultSyncDestinationCol secretSync={secretSync} />;
default:
throw new Error(
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
@@ -102,6 +102,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
primaryText = destinationConfig.project;
secondaryText = destinationConfig.buildConfig;
break;
case SecretSync.OCIVault:
primaryText = destinationConfig.compartmentOcid;
secondaryText = destinationConfig.vaultOcid;
break;
default:
throw new Error(`Unhandled Destination Col Values ${destination}`);
}
@@ -0,0 +1,42 @@
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { Tooltip } from "@app/components/v2";
import { TOCIVaultSync } from "@app/hooks/api/secretSyncs/types/oci-vault-sync";
type Props = {
secretSync: TOCIVaultSync;
};
export const OCIVaultSyncDestinationSection = ({ secretSync }: Props) => {
const {
destinationConfig: { compartmentOcid, keyOcid, vaultOcid }
} = secretSync;
return (
<>
<Tooltip side="bottom" className="max-w-sm select-text break-words" content={compartmentOcid}>
<div>
<GenericFieldLabel label="Compartment OCID">
{compartmentOcid.substring(0, 21)}...
{compartmentOcid.substring(compartmentOcid.length - 6)}
</GenericFieldLabel>
</div>
</Tooltip>
<Tooltip side="bottom" className="max-w-sm select-text break-words" content={compartmentOcid}>
<div>
<GenericFieldLabel label="Vault OCID">
{vaultOcid.substring(0, 15)}...
{vaultOcid.substring(vaultOcid.length - 6)}
</GenericFieldLabel>
</div>
</Tooltip>
<Tooltip side="bottom" className="max-w-sm select-text break-words" content={compartmentOcid}>
<div>
<GenericFieldLabel label="Key OCID">
{keyOcid.substring(0, 13)}...
{keyOcid.substring(keyOcid.length - 6)}
</GenericFieldLabel>
</div>
</Tooltip>
</>
);
};
@@ -20,6 +20,7 @@ import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection";
import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection";
import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection";
import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection";
import { OCIVaultSyncDestinationSection } from "./OCIVaultSyncDestinationSection";
import { TeamCitySyncDestinationSection } from "./TeamCitySyncDestinationSection";
import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection";
import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection";
@@ -81,6 +82,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
case SecretSync.TeamCity:
DestinationComponents = <TeamCitySyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.OCIVault:
DestinationComponents = <OCIVaultSyncDestinationSection secretSync={secretSync} />;
break;
default:
throw new Error(`Unhandled Destination Section components: ${destination}`);
}
@@ -54,6 +54,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
case SecretSync.Windmill:
case SecretSync.HCVault:
case SecretSync.TeamCity:
case SecretSync.OCIVault:
AdditionalSyncOptionsComponent = null;
break;
default:
@@ -30,7 +30,7 @@ import {
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSshHostActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { fetchSshHostUserCaPublicKey, useListWorkspaceSshHosts } from "@app/hooks/api";
import { LoginMappingSource } from "@app/hooks/api/sshHost/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -221,7 +221,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
Download User CA Public Key
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
I={ProjectPermissionSshHostActions.Edit}
a={ProjectPermissionSub.SshHosts}
>
{(isAllowed) => (
@@ -243,7 +243,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
I={ProjectPermissionSshHostActions.Delete}
a={ProjectPermissionSub.SshHosts}
>
{(isAllowed) => (