mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-09 09:28:28 +00:00
Merge branch 'main' into feature/ldap-gateway-support
This commit is contained in:
@@ -2,10 +2,15 @@ import { forwardRef, TextareaHTMLAttributes, useCallback, useMemo, useRef, useSt
|
||||
import { faFolder, faKey, faLayerGroup, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { useProject } from "@app/context";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useProject, useProjectPermission } from "@app/context";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { useDebounce, useToggle } from "@app/hooks";
|
||||
import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
|
||||
import { SecretInput } from "../SecretInput";
|
||||
|
||||
@@ -80,6 +85,8 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
) => {
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.id || "";
|
||||
const navigate = useNavigate({ from: ROUTE_PATHS.SecretManager.SecretDashboardPage.path });
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const [debouncedValue] = useDebounce(value, 100);
|
||||
|
||||
@@ -307,6 +314,120 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClickSegment = useCallback(
|
||||
(segment: string, allSegments: string[]) => {
|
||||
if (!projectId) {
|
||||
createNotification({
|
||||
text: "Project ID is not set",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (allSegments.length === 0) {
|
||||
createNotification({
|
||||
text: "Invalid secret reference",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (allSegments.length === 1) {
|
||||
const canReadSecretValue = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
{
|
||||
environment: propEnvironment ?? "*",
|
||||
secretPath: propSecretPath ?? "/",
|
||||
secretName: segment,
|
||||
secretTags: ["*"]
|
||||
}
|
||||
);
|
||||
|
||||
if (!canReadSecretValue) {
|
||||
createNotification({
|
||||
text: "You do not have permission to access this secret",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
search: segment,
|
||||
filterBy: "secret",
|
||||
tags: ""
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const environmentSlug = allSegments[0];
|
||||
const secretName = allSegments[allSegments.length - 1];
|
||||
let folderPath = "/";
|
||||
|
||||
if (allSegments.length > 2) {
|
||||
const pathSegments = allSegments.slice(1, -1);
|
||||
for (let i = 0; i < pathSegments.length; i += 1) {
|
||||
if (!pathSegments[i]) {
|
||||
createNotification({
|
||||
text: "Invalid secret reference",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pathSegment = pathSegments[i];
|
||||
folderPath += `${pathSegment}`;
|
||||
if (pathSegment === segment) {
|
||||
folderPath += "/";
|
||||
break;
|
||||
}
|
||||
folderPath += "/";
|
||||
}
|
||||
}
|
||||
|
||||
// Only validate secret permission, users can always view environments and folders
|
||||
if (segment === secretName) {
|
||||
const canReadSecretValue = hasSecretReadValueOrDescribePermission(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
{
|
||||
environment: environmentSlug,
|
||||
secretPath: folderPath,
|
||||
secretName,
|
||||
secretTags: ["*"]
|
||||
}
|
||||
);
|
||||
|
||||
if (!canReadSecretValue) {
|
||||
createNotification({
|
||||
text: "You do not have permission to access this secret",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
navigate({
|
||||
to: ROUTE_PATHS.SecretManager.SecretDashboardPage.path,
|
||||
params: {
|
||||
projectId,
|
||||
envSlug: environmentSlug
|
||||
},
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
secretPath: segment === environmentSlug ? "/" : folderPath,
|
||||
search: segment === secretName ? secretName : prev.search,
|
||||
filterBy: segment === secretName ? "secret" : prev.filterBy,
|
||||
tags: ""
|
||||
})
|
||||
});
|
||||
},
|
||||
[navigate, projectId, permission, propEnvironment, propSecretPath]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover.Root open={isPopupOpen} onOpenChange={handlePopUpOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
@@ -329,6 +450,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
}}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
containerClassName={containerClassName}
|
||||
onClickSegment={handleClickSegment}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
/* eslint-disable react/no-danger */
|
||||
import { forwardRef, TextareaHTMLAttributes } from "react";
|
||||
import { forwardRef, TextareaHTMLAttributes, useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
|
||||
|
||||
const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g;
|
||||
const REGEX = /(\${([a-zA-Z0-9-_. ]+)})/g;
|
||||
|
||||
const syntaxHighlight = (
|
||||
content?: string | null,
|
||||
isVisible?: boolean,
|
||||
isImport?: boolean,
|
||||
isLoadingValue?: boolean,
|
||||
isErrorLoadingValue?: boolean
|
||||
isErrorLoadingValue?: boolean,
|
||||
onHoverPart?: (part: string) => void,
|
||||
hoveredPart?: string,
|
||||
isCmdOrCtrlPressed?: boolean,
|
||||
onClickSegment?: (segment: string, allSegments: string[]) => void,
|
||||
placeholder?: string
|
||||
) => {
|
||||
if (isLoadingValue) return HIDDEN_SECRET_VALUE;
|
||||
if (isErrorLoadingValue)
|
||||
return <span className="ph-no-capture text-red/75">Error loading secret value.</span>;
|
||||
if (isImport && !content) return "IMPORTED";
|
||||
if (placeholder && (content === "" || !content)) return placeholder;
|
||||
if (content === "") return "EMPTY";
|
||||
if (!content) return "EMPTY";
|
||||
if (!isVisible) return HIDDEN_SECRET_VALUE;
|
||||
@@ -27,10 +33,57 @@ const syntaxHighlight = (
|
||||
const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}");
|
||||
if (isInterpolationSyntax) {
|
||||
skipNext = true;
|
||||
const part = el;
|
||||
const innerContent = el.slice(2, -1); // Remove ${ and }
|
||||
const parts = innerContent.split(".");
|
||||
|
||||
return (
|
||||
<span className="ph-no-capture text-yellow" key={`secret-value-${i + 1}`}>
|
||||
<span className="ph-no-capture relative z-10 text-yellow" key={`secret-value-${i + 1}`}>
|
||||
${
|
||||
<span className="ph-no-capture text-yellow-200/80">{el.slice(2, -1)}</span>
|
||||
{parts.map((segment, segmentIndex) => {
|
||||
const segmentKey = `${part}-segment-${segmentIndex}`;
|
||||
const isHovered = hoveredPart === segmentKey;
|
||||
const shouldShowHoverStyle = isHovered && isCmdOrCtrlPressed;
|
||||
|
||||
return (
|
||||
<span key={segmentKey}>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={isCmdOrCtrlPressed ? 0 : -1}
|
||||
className={`ph-no-capture text-yellow-200/80 ${
|
||||
isCmdOrCtrlPressed ? "pointer-events-auto" : "pointer-events-none"
|
||||
} ${shouldShowHoverStyle ? "cursor-pointer underline decoration-yellow-400" : ""}`}
|
||||
onMouseEnter={() => onHoverPart?.(segmentKey)}
|
||||
onMouseLeave={() => onHoverPart?.("")}
|
||||
onMouseDown={(e) => {
|
||||
if (isCmdOrCtrlPressed) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isCmdOrCtrlPressed) {
|
||||
e.preventDefault();
|
||||
onClickSegment?.(segment, parts);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isCmdOrCtrlPressed && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClickSegment?.(segment, parts);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{segment}
|
||||
</span>
|
||||
{segmentIndex < parts.length - 1 && (
|
||||
<span className="ph-no-capture pointer-events-none text-yellow-200/80">.</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
}
|
||||
</span>
|
||||
);
|
||||
@@ -60,6 +113,7 @@ type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
canEditButNotView?: boolean;
|
||||
isLoadingValue?: boolean;
|
||||
isErrorLoadingValue?: boolean;
|
||||
onClickSegment?: (segment: string, allSegments: string[]) => void;
|
||||
};
|
||||
|
||||
const commonClassName = "font-mono text-sm caret-white border-none outline-hidden w-full break-all";
|
||||
@@ -79,11 +133,43 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
canEditButNotView,
|
||||
isLoadingValue,
|
||||
isErrorLoadingValue,
|
||||
onClickSegment,
|
||||
placeholder,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isSecretFocused, setIsSecretFocused] = useToggle();
|
||||
const [hoveredPart, setHoveredPart] = useState<string | undefined>();
|
||||
const [isCmdOrCtrlPressed, setIsCmdOrCtrlPressed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
setIsCmdOrCtrlPressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (!e.metaKey && !e.ctrlKey) {
|
||||
setIsCmdOrCtrlPressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsCmdOrCtrlPressed(false);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -91,20 +177,33 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
style={{ maxHeight: `${21 * 7}px` }}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<pre aria-hidden className="m-0">
|
||||
<pre aria-hidden className="pointer-events-none relative z-10 m-0">
|
||||
<code className={`inline-block w-full ${commonClassName}`}>
|
||||
<span style={{ whiteSpace: "break-spaces" }}>
|
||||
<span
|
||||
className={twMerge(
|
||||
"whitespace-break-spaces",
|
||||
placeholder && !value && "text-gray-500/50"
|
||||
)}
|
||||
>
|
||||
{syntaxHighlight(
|
||||
value,
|
||||
isVisible || (isSecretFocused && !valueAlwaysHidden),
|
||||
isImport,
|
||||
isLoadingValue,
|
||||
isErrorLoadingValue
|
||||
isErrorLoadingValue,
|
||||
(part) => {
|
||||
setHoveredPart(part);
|
||||
},
|
||||
hoveredPart,
|
||||
isCmdOrCtrlPressed,
|
||||
onClickSegment,
|
||||
placeholder
|
||||
)}
|
||||
</span>
|
||||
</code>
|
||||
</pre>
|
||||
<textarea
|
||||
placeholder={placeholder}
|
||||
style={{ whiteSpace: "break-spaces" }}
|
||||
aria-label="secret value"
|
||||
ref={ref}
|
||||
@@ -128,6 +227,9 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
onBlur?.(evt);
|
||||
setIsSecretFocused.off();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredPart(undefined);
|
||||
}}
|
||||
value={value || ""}
|
||||
{...props}
|
||||
readOnly={isReadOnly || isLoadingValue || isErrorLoadingValue}
|
||||
|
||||
@@ -26,6 +26,9 @@ export const envConfig = {
|
||||
import.meta.env.VITE_TELEMETRY_CAPTURING_ENABLED === true
|
||||
);
|
||||
},
|
||||
get ACME_FEATURE_ENABLED() {
|
||||
return window?.__INFISICAL_RUNTIME_ENV__?.ACME_FEATURE_ENABLED ?? false;
|
||||
},
|
||||
|
||||
get PLATFORM_VERSION() {
|
||||
return import.meta.env.VITE_INFISICAL_PLATFORM_VERSION;
|
||||
|
||||
@@ -129,7 +129,8 @@ export enum ProjectPermissionCertificateProfileActions {
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
IssueCert = "issue-cert"
|
||||
IssueCert = "issue-cert",
|
||||
RevealAcmeEabSecret = "reveal-acme-eab-secret"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionSecretRotationActions {
|
||||
|
||||
Vendored
+1
@@ -7,6 +7,7 @@ declare global {
|
||||
POSTHOG_API_KEY?: string;
|
||||
INTERCOM_ID?: string;
|
||||
TELEMETRY_CAPTURING_ENABLED: string;
|
||||
ACME_FEATURE_ENABLED?: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { faGithub, IconDefinition } from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faBullseye,
|
||||
faCertificate,
|
||||
faKey,
|
||||
faLink,
|
||||
faLock,
|
||||
@@ -211,6 +212,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
|
||||
case AzureKeyVaultConnectionMethod.ClientSecret:
|
||||
case AzureDevOpsConnectionMethod.ClientSecret:
|
||||
return { name: "Client Secret", icon: faKey };
|
||||
case AzureClientSecretsConnectionMethod.Certificate:
|
||||
return { name: "Certificate", icon: faCertificate };
|
||||
default:
|
||||
throw new Error(`Unhandled App Connection Method: ${method}`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-con
|
||||
|
||||
export enum AzureClientSecretsConnectionMethod {
|
||||
OAuth = "oauth",
|
||||
ClientSecret = "client-secret"
|
||||
ClientSecret = "client-secret",
|
||||
Certificate = "certificate"
|
||||
}
|
||||
|
||||
export type TAzureClientSecretsConnection = TRootAppConnection & {
|
||||
@@ -24,4 +25,13 @@ export type TAzureClientSecretsConnection = TRootAppConnection & {
|
||||
tenantId: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
method: AzureClientSecretsConnectionMethod.Certificate;
|
||||
credentials: {
|
||||
clientId: string;
|
||||
tenantId: string;
|
||||
certificateBody: string;
|
||||
privateKey: string;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -268,7 +268,18 @@ export const eventToNameMap: { [K in EventType]: string } = {
|
||||
[EventType.PAM_RESOURCE_GET]: "PAM Resource Get",
|
||||
[EventType.PAM_RESOURCE_CREATE]: "PAM Resource Create",
|
||||
[EventType.PAM_RESOURCE_UPDATE]: "PAM Resource Update",
|
||||
[EventType.PAM_RESOURCE_DELETE]: "PAM Resource Delete"
|
||||
[EventType.PAM_RESOURCE_DELETE]: "PAM Resource Delete",
|
||||
|
||||
[EventType.CREATE_CERTIFICATE_PROFILE]: "Create Certificate Profile",
|
||||
[EventType.UPDATE_CERTIFICATE_PROFILE]: "Update Certificate Profile",
|
||||
[EventType.DELETE_CERTIFICATE_PROFILE]: "Delete Certificate Profile",
|
||||
[EventType.GET_CERTIFICATE_PROFILE]: "Get Certificate Profile",
|
||||
[EventType.LIST_CERTIFICATE_PROFILES]: "List Certificate Profiles",
|
||||
[EventType.ISSUE_CERTIFICATE_FROM_PROFILE]: "Issue Certificate From Profile",
|
||||
[EventType.SIGN_CERTIFICATE_FROM_PROFILE]: "Sign Certificate From Profile",
|
||||
[EventType.ORDER_CERTIFICATE_FROM_PROFILE]: "Order Certificate From Profile",
|
||||
[EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE]:
|
||||
"Get Certificate Profile Latest Active Bundle"
|
||||
};
|
||||
|
||||
export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = {
|
||||
|
||||
@@ -260,5 +260,15 @@ export enum EventType {
|
||||
PAM_RESOURCE_GET = "pam-resource-get",
|
||||
PAM_RESOURCE_CREATE = "pam-resource-create",
|
||||
PAM_RESOURCE_UPDATE = "pam-resource-update",
|
||||
PAM_RESOURCE_DELETE = "pam-resource-delete"
|
||||
PAM_RESOURCE_DELETE = "pam-resource-delete",
|
||||
|
||||
CREATE_CERTIFICATE_PROFILE = "create-certificate-profile",
|
||||
UPDATE_CERTIFICATE_PROFILE = "update-certificate-profile",
|
||||
DELETE_CERTIFICATE_PROFILE = "delete-certificate-profile",
|
||||
GET_CERTIFICATE_PROFILE = "get-certificate-profile",
|
||||
LIST_CERTIFICATE_PROFILES = "list-certificate-profiles",
|
||||
ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile",
|
||||
SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
|
||||
ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile",
|
||||
GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle"
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ export const useCreateCertificateV3 = (options?: { projectId?: string }) => {
|
||||
return useMutation<TCreateCertificateV3Response, object, TCreateCertificateV3DTO>({
|
||||
mutationFn: async (body) => {
|
||||
const { data } = await apiRequest.post<TCreateCertificateV3Response>(
|
||||
"/api/v3/certificates/issue-certificate",
|
||||
"/api/v3/pki/certificates/issue-certificate",
|
||||
body
|
||||
);
|
||||
return data;
|
||||
@@ -185,7 +185,7 @@ export const useOrderCertificateWithProfile = () => {
|
||||
return useMutation<TOrderCertificateResponse, object, TOrderCertificateDTO>({
|
||||
mutationFn: async (body) => {
|
||||
const { data } = await apiRequest.post<TOrderCertificateResponse>(
|
||||
"/api/v3/certificates/order-certificate",
|
||||
"/api/v3/pki/certificates/order-certificate",
|
||||
body
|
||||
);
|
||||
return data;
|
||||
|
||||
@@ -159,8 +159,8 @@ export type TCreateCertificateDTO = {
|
||||
ttl: string; // string compatible with ms
|
||||
notBefore?: string;
|
||||
notAfter?: string;
|
||||
keyUsages: CertKeyUsage[];
|
||||
extendedKeyUsages: CertExtendedKeyUsage[];
|
||||
keyUsages: string[];
|
||||
extendedKeyUsages: string[];
|
||||
};
|
||||
|
||||
export type TCreateCertificateResponse = {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
TGetProfileCertificatesDTO,
|
||||
TGetProfileMetricsDTO,
|
||||
TListCertificateProfilesDTO,
|
||||
TProfileCertificate
|
||||
TProfileCertificate,
|
||||
TRevealAcmeEabSecretDTO
|
||||
} from "./types";
|
||||
|
||||
export const certificateProfileKeys = {
|
||||
@@ -41,6 +42,11 @@ export const certificateProfileKeys = {
|
||||
"metrics",
|
||||
profileId,
|
||||
params
|
||||
],
|
||||
revealAcmeEabSecret: (profileId: string) => [
|
||||
"certificate-profiles",
|
||||
"reveal-acme-eab-secret",
|
||||
profileId
|
||||
]
|
||||
};
|
||||
|
||||
@@ -112,6 +118,20 @@ export const useGetCertificateProfileBySlug = ({
|
||||
});
|
||||
};
|
||||
|
||||
export const useRevealAcmeEabSecret = ({ profileId }: TRevealAcmeEabSecretDTO) => {
|
||||
return useQuery({
|
||||
queryKey: certificateProfileKeys.revealAcmeEabSecret(profileId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
eabKid: string;
|
||||
eabSecret: string;
|
||||
}>(`/api/v1/pki/certificate-profiles/${profileId}/acme/eab-secret/reveal`);
|
||||
return data;
|
||||
},
|
||||
enabled: Boolean(profileId)
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetProfileCertificates = ({
|
||||
profileId,
|
||||
offset = 0,
|
||||
|
||||
@@ -5,7 +5,7 @@ export type TCertificateProfile = {
|
||||
certificateTemplateId: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
enrollmentType: "api" | "est";
|
||||
enrollmentType: "api" | "est" | "acme";
|
||||
estConfigId?: string;
|
||||
apiConfigId?: string;
|
||||
createdAt: string;
|
||||
@@ -36,6 +36,10 @@ export type TCertificateProfileWithDetails = TCertificateProfile & {
|
||||
autoRenew: boolean;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
acmeConfig?: {
|
||||
id: string;
|
||||
directoryUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TCreateCertificateProfileDTO = {
|
||||
@@ -44,7 +48,7 @@ export type TCreateCertificateProfileDTO = {
|
||||
certificateTemplateId: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
enrollmentType: "api" | "est";
|
||||
enrollmentType: "api" | "est" | "acme";
|
||||
estConfig?: {
|
||||
disableBootstrapCaValidation?: boolean;
|
||||
passphrase: string;
|
||||
@@ -54,6 +58,7 @@ export type TCreateCertificateProfileDTO = {
|
||||
autoRenew?: boolean;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
acmeConfig?: unknown;
|
||||
};
|
||||
|
||||
export type TUpdateCertificateProfileDTO = {
|
||||
@@ -69,6 +74,7 @@ export type TUpdateCertificateProfileDTO = {
|
||||
autoRenew?: boolean;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
acmeConfig?: unknown;
|
||||
};
|
||||
|
||||
export type TDeleteCertificateProfileDTO = {
|
||||
@@ -81,7 +87,7 @@ export type TListCertificateProfilesDTO = {
|
||||
offset?: number;
|
||||
search?: string;
|
||||
includeConfigs?: boolean;
|
||||
enrollmentType?: "api" | "est";
|
||||
enrollmentType?: "api" | "est" | "acme";
|
||||
};
|
||||
|
||||
export type TGetCertificateProfileByIdDTO = {
|
||||
@@ -93,6 +99,10 @@ export type TGetCertificateProfileBySlugDTO = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type TRevealAcmeEabSecretDTO = {
|
||||
profileId: string;
|
||||
};
|
||||
|
||||
export type TProfileCertificate = {
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
|
||||
@@ -90,7 +90,12 @@ export const useCreateCertTemplateV2 = () => {
|
||||
return data.certificateTemplate;
|
||||
},
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: certTemplateKeys.listTemplates({ projectId }) });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const [firstKey, queryProjectId] = query.queryKey;
|
||||
return firstKey === "list-template" && queryProjectId === projectId;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -107,7 +112,12 @@ export const useUpdateCertTemplateV2 = () => {
|
||||
return data.certificateTemplate;
|
||||
},
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: certTemplateKeys.listTemplates({ projectId }) });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const [firstKey, queryProjectId] = query.queryKey;
|
||||
return firstKey === "list-template" && queryProjectId === projectId;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -127,7 +137,12 @@ export const useDeleteCertTemplateV2 = () => {
|
||||
return data.certificateTemplate;
|
||||
},
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: certTemplateKeys.listTemplates({ projectId }) });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const [firstKey, queryProjectId] = query.queryKey;
|
||||
return firstKey === "list-template" && queryProjectId === projectId;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { CertStatus } from "./enums";
|
||||
export {
|
||||
useDeleteCert,
|
||||
useDownloadCertPkcs12,
|
||||
useImportCertificate,
|
||||
useRenewCertificate,
|
||||
useRevokeCert,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { projectKeys } from "../projects";
|
||||
import {
|
||||
TCertificate,
|
||||
TDeleteCertDTO,
|
||||
TDownloadPkcs12DTO,
|
||||
TImportCertificateDTO,
|
||||
TImportCertificateResponse,
|
||||
TRenewCertificateDTO,
|
||||
@@ -86,7 +87,7 @@ export const useRenewCertificate = () => {
|
||||
return useMutation<TRenewCertificateResponse, object, TRenewCertificateDTO>({
|
||||
mutationFn: async ({ certificateId }) => {
|
||||
const { data } = await apiRequest.post<TRenewCertificateResponse>(
|
||||
`/api/v3/certificates/${certificateId}/renew`,
|
||||
`/api/v3/pki/certificates/${certificateId}/renew`,
|
||||
{}
|
||||
);
|
||||
return data;
|
||||
@@ -119,7 +120,7 @@ export const useUpdateRenewalConfig = () => {
|
||||
>({
|
||||
mutationFn: async ({ certificateId, renewBeforeDays, enableAutoRenewal }) => {
|
||||
const { data } = await apiRequest.patch<{ message: string; renewBeforeDays?: number }>(
|
||||
`/api/v3/certificates/${certificateId}/config`,
|
||||
`/api/v3/pki/certificates/${certificateId}/config`,
|
||||
{ renewBeforeDays, enableAutoRenewal }
|
||||
);
|
||||
return data;
|
||||
@@ -134,3 +135,42 @@ export const useUpdateRenewalConfig = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDownloadCertPkcs12 = () => {
|
||||
return useMutation<void, object, TDownloadPkcs12DTO>({
|
||||
mutationFn: async ({ serialNumber, projectSlug, password, alias }) => {
|
||||
try {
|
||||
const response = await apiRequest.post(
|
||||
`/api/v1/pki/certificates/${serialNumber}/pkcs12`,
|
||||
{
|
||||
password,
|
||||
alias
|
||||
},
|
||||
{
|
||||
params: { projectSlug },
|
||||
responseType: "arraybuffer"
|
||||
}
|
||||
);
|
||||
|
||||
// Create blob and trigger download
|
||||
const blob = new Blob([response.data], { type: "application/octet-stream" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `certificate-${serialNumber}.p12`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error: any) {
|
||||
if (error.response?.data instanceof ArrayBuffer) {
|
||||
const decoder = new TextDecoder();
|
||||
const errorText = decoder.decode(error.response.data);
|
||||
const errorData = JSON.parse(errorText);
|
||||
throw new Error(errorData.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -72,3 +72,10 @@ export type TUpdateRenewalConfigDTO = {
|
||||
enableAutoRenewal?: boolean;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export type TDownloadPkcs12DTO = {
|
||||
serialNumber: string;
|
||||
projectSlug: string;
|
||||
password: string;
|
||||
alias: string;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@ export enum PamResourceType {
|
||||
MySQL = "mysql",
|
||||
RDP = "rdp",
|
||||
SSH = "ssh",
|
||||
Kubernetes = "kubernetes"
|
||||
Kubernetes = "kubernetes",
|
||||
MCP = "mcp"
|
||||
}
|
||||
|
||||
export enum PamSessionStatus {
|
||||
|
||||
@@ -8,5 +8,6 @@ export const PAM_RESOURCE_TYPE_MAP: Record<
|
||||
[PamResourceType.MySQL]: { name: "MySQL", image: "MySql.png" },
|
||||
[PamResourceType.RDP]: { name: "RDP", image: "RDP.png" },
|
||||
[PamResourceType.SSH]: { name: "SSH", image: "SSH.png" },
|
||||
[PamResourceType.Kubernetes]: { name: "Kubernetes", image: "Kubernetes.png" }
|
||||
[PamResourceType.Kubernetes]: { name: "Kubernetes", image: "Kubernetes.png" },
|
||||
[PamResourceType.MCP]: { name: "MCP", image: "MCP.png" }
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export { useCreatePkiAlertV2, useDeletePkiAlertV2, useUpdatePkiAlertV2 } from "./mutations";
|
||||
export {
|
||||
pkiAlertsV2Keys,
|
||||
useGetPkiAlertsV2,
|
||||
useGetPkiAlertV2ById,
|
||||
useGetPkiAlertV2CurrentMatchingCertificates,
|
||||
useGetPkiAlertV2MatchingCertificates
|
||||
} from "./queries";
|
||||
export type {
|
||||
TCreatePkiAlertV2,
|
||||
TDeletePkiAlertV2,
|
||||
TGetPkiAlertsV2,
|
||||
TGetPkiAlertV2ById,
|
||||
TGetPkiAlertV2CurrentMatchingCertificates,
|
||||
TGetPkiAlertV2CurrentMatchingCertificatesResponse,
|
||||
TGetPkiAlertV2MatchingCertificates,
|
||||
TPkiAlertChannelConfigEmail,
|
||||
TPkiAlertChannelV2,
|
||||
TPkiAlertV2,
|
||||
TPkiFilterRuleV2,
|
||||
TUpdatePkiAlertV2
|
||||
} from "./types";
|
||||
export {
|
||||
createPkiAlertV2Schema,
|
||||
PkiAlertChannelTypeV2,
|
||||
PkiAlertEventTypeV2,
|
||||
PkiFilterFieldV2,
|
||||
PkiFilterOperatorV2,
|
||||
updatePkiAlertV2Schema
|
||||
} from "./types";
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { pkiAlertsV2Keys } from "./queries";
|
||||
import { TCreatePkiAlertV2, TDeletePkiAlertV2, TPkiAlertV2, TUpdatePkiAlertV2 } from "./types";
|
||||
|
||||
export const useCreatePkiAlertV2 = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TPkiAlertV2, unknown, TCreatePkiAlertV2>({
|
||||
mutationFn: async (data) => {
|
||||
const { data: response } = await apiRequest.post<{ alert: TPkiAlertV2 }>(
|
||||
"/api/v2/pki/alerts",
|
||||
data
|
||||
);
|
||||
return response.alert;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: pkiAlertsV2Keys.allPkiAlertsV2({ projectId: variables.projectId })
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdatePkiAlertV2 = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TPkiAlertV2, unknown, TUpdatePkiAlertV2>({
|
||||
mutationFn: async ({ alertId, ...data }) => {
|
||||
const { data: response } = await apiRequest.patch<{ alert: TPkiAlertV2 }>(
|
||||
`/api/v2/pki/alerts/${alertId}`,
|
||||
data
|
||||
);
|
||||
return response.alert;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: pkiAlertsV2Keys.specificPkiAlertV2(variables.alertId)
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: pkiAlertsV2Keys.all
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeletePkiAlertV2 = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TPkiAlertV2, unknown, TDeletePkiAlertV2>({
|
||||
mutationFn: async ({ alertId }) => {
|
||||
const { data } = await apiRequest.delete<{ alert: TPkiAlertV2 }>(
|
||||
`/api/v2/pki/alerts/${alertId}`
|
||||
);
|
||||
return data.alert;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: pkiAlertsV2Keys.all
|
||||
});
|
||||
queryClient.removeQueries({
|
||||
queryKey: pkiAlertsV2Keys.specificPkiAlertV2(variables.alertId)
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import {
|
||||
TGetPkiAlertsV2,
|
||||
TGetPkiAlertsV2Response,
|
||||
TGetPkiAlertV2ById,
|
||||
TGetPkiAlertV2CurrentMatchingCertificates,
|
||||
TGetPkiAlertV2CurrentMatchingCertificatesResponse,
|
||||
TGetPkiAlertV2MatchingCertificates,
|
||||
TGetPkiAlertV2MatchingCertificatesResponse,
|
||||
TPkiAlertV2
|
||||
} from "./types";
|
||||
|
||||
export const pkiAlertsV2Keys = {
|
||||
all: ["pki-alerts-v2"] as const,
|
||||
allPkiAlertsV2: (filters?: TGetPkiAlertsV2) => [pkiAlertsV2Keys.all[0], filters] as const,
|
||||
specificPkiAlertV2: (alertId: string) => [...pkiAlertsV2Keys.all, alertId] as const,
|
||||
pkiAlertV2MatchingCertificates: (alertId: string, filters?: TGetPkiAlertV2MatchingCertificates) =>
|
||||
[...pkiAlertsV2Keys.specificPkiAlertV2(alertId), "certificates", filters] as const,
|
||||
pkiAlertV2CurrentMatchingCertificates: (filters?: TGetPkiAlertV2CurrentMatchingCertificates) =>
|
||||
[...pkiAlertsV2Keys.all, "current-certificates", filters] as const
|
||||
};
|
||||
|
||||
const fetchPkiAlertsV2 = async (params: TGetPkiAlertsV2): Promise<TGetPkiAlertsV2Response> => {
|
||||
const { data } = await apiRequest.get<TGetPkiAlertsV2Response>("/api/v2/pki/alerts", {
|
||||
params
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
const fetchPkiAlertV2ById = async ({ alertId }: TGetPkiAlertV2ById): Promise<TPkiAlertV2> => {
|
||||
const { data } = await apiRequest.get<{ alert: TPkiAlertV2 }>(`/api/v2/pki/alerts/${alertId}`);
|
||||
return data.alert;
|
||||
};
|
||||
|
||||
const fetchPkiAlertV2MatchingCertificates = async (
|
||||
params: TGetPkiAlertV2MatchingCertificates
|
||||
): Promise<TGetPkiAlertV2MatchingCertificatesResponse> => {
|
||||
const { alertId, ...queryParams } = params;
|
||||
const { data } = await apiRequest.get<TGetPkiAlertV2MatchingCertificatesResponse>(
|
||||
`/api/v2/pki/alerts/${alertId}/certificates`,
|
||||
{ params: queryParams }
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
const fetchPkiAlertV2CurrentMatchingCertificates = async (
|
||||
params: TGetPkiAlertV2CurrentMatchingCertificates
|
||||
): Promise<TGetPkiAlertV2CurrentMatchingCertificatesResponse> => {
|
||||
const { data } = await apiRequest.post<TGetPkiAlertV2CurrentMatchingCertificatesResponse>(
|
||||
"/api/v2/pki/alerts/preview/certificates",
|
||||
params
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const useGetPkiAlertsV2 = (
|
||||
params: TGetPkiAlertsV2,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TGetPkiAlertsV2Response,
|
||||
unknown,
|
||||
TGetPkiAlertsV2Response,
|
||||
ReturnType<typeof pkiAlertsV2Keys.allPkiAlertsV2>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pkiAlertsV2Keys.allPkiAlertsV2(params),
|
||||
queryFn: () => fetchPkiAlertsV2(params),
|
||||
enabled: !!params.projectId,
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetPkiAlertV2ById = (
|
||||
params: TGetPkiAlertV2ById,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TPkiAlertV2,
|
||||
unknown,
|
||||
TPkiAlertV2,
|
||||
ReturnType<typeof pkiAlertsV2Keys.specificPkiAlertV2>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pkiAlertsV2Keys.specificPkiAlertV2(params.alertId),
|
||||
queryFn: () => fetchPkiAlertV2ById(params),
|
||||
enabled: !!params.alertId,
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetPkiAlertV2MatchingCertificates = (
|
||||
params: TGetPkiAlertV2MatchingCertificates,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TGetPkiAlertV2MatchingCertificatesResponse,
|
||||
unknown,
|
||||
TGetPkiAlertV2MatchingCertificatesResponse,
|
||||
ReturnType<typeof pkiAlertsV2Keys.pkiAlertV2MatchingCertificates>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pkiAlertsV2Keys.pkiAlertV2MatchingCertificates(params.alertId, params),
|
||||
queryFn: () => fetchPkiAlertV2MatchingCertificates(params),
|
||||
enabled: !!params.alertId,
|
||||
placeholderData: (previousData) => previousData,
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetPkiAlertV2CurrentMatchingCertificates = (
|
||||
params: TGetPkiAlertV2CurrentMatchingCertificates,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TGetPkiAlertV2CurrentMatchingCertificatesResponse,
|
||||
unknown,
|
||||
TGetPkiAlertV2CurrentMatchingCertificatesResponse,
|
||||
ReturnType<typeof pkiAlertsV2Keys.pkiAlertV2CurrentMatchingCertificates>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: pkiAlertsV2Keys.pkiAlertV2CurrentMatchingCertificates(params),
|
||||
queryFn: () => fetchPkiAlertV2CurrentMatchingCertificates(params),
|
||||
enabled: !!params.projectId && params.filters !== undefined,
|
||||
placeholderData: (previousData) => previousData,
|
||||
...options
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export enum PkiAlertEventTypeV2 {
|
||||
EXPIRATION = "expiration",
|
||||
RENEWAL = "renewal",
|
||||
ISSUANCE = "issuance",
|
||||
REVOCATION = "revocation"
|
||||
}
|
||||
|
||||
export enum PkiAlertChannelTypeV2 {
|
||||
EMAIL = "email"
|
||||
}
|
||||
|
||||
export enum PkiFilterFieldV2 {
|
||||
COMMON_NAME = "common_name",
|
||||
PROFILE_NAME = "profile_name",
|
||||
SAN = "san",
|
||||
INCLUDE_CAS = "include_cas"
|
||||
}
|
||||
|
||||
export enum PkiFilterOperatorV2 {
|
||||
EQUALS = "equals",
|
||||
CONTAINS = "contains",
|
||||
STARTS_WITH = "starts_with",
|
||||
ENDS_WITH = "ends_with",
|
||||
MATCHES = "matches"
|
||||
}
|
||||
|
||||
export interface TPkiFilterRuleV2 {
|
||||
field: PkiFilterFieldV2;
|
||||
operator: PkiFilterOperatorV2;
|
||||
value: string | string[] | boolean;
|
||||
}
|
||||
|
||||
export interface TPkiAlertChannelConfigEmail {
|
||||
recipients: string[];
|
||||
}
|
||||
|
||||
// In the future other channels like webhooks will be supported here
|
||||
export type TPkiAlertChannelConfig = TPkiAlertChannelConfigEmail;
|
||||
|
||||
export interface TPkiAlertChannelV2 {
|
||||
id: string;
|
||||
channelType: PkiAlertChannelTypeV2;
|
||||
config: TPkiAlertChannelConfig;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TPkiAlertV2 {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
eventType: PkiAlertEventTypeV2;
|
||||
alertBefore?: string;
|
||||
filters: TPkiFilterRuleV2[];
|
||||
enabled: boolean;
|
||||
channels: TPkiAlertChannelV2[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TPkiCertificateMatchV2 {
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
commonName?: string;
|
||||
san?: string[];
|
||||
profileName?: string;
|
||||
enrollmentType?: string;
|
||||
notBefore: string;
|
||||
notAfter: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TGetPkiAlertsV2 {
|
||||
projectId: string;
|
||||
search?: string;
|
||||
eventType?: PkiAlertEventTypeV2;
|
||||
enabled?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface TGetPkiAlertsV2Response {
|
||||
alerts: TPkiAlertV2[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TGetPkiAlertV2ById {
|
||||
alertId: string;
|
||||
}
|
||||
|
||||
export interface TCreatePkiAlertV2 {
|
||||
projectId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
eventType: PkiAlertEventTypeV2;
|
||||
alertBefore?: string;
|
||||
filters: TPkiFilterRuleV2[];
|
||||
enabled?: boolean;
|
||||
channels: Omit<TPkiAlertChannelV2, "id" | "createdAt" | "updatedAt">[];
|
||||
}
|
||||
|
||||
export interface TUpdatePkiAlertV2 {
|
||||
alertId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
eventType?: PkiAlertEventTypeV2;
|
||||
alertBefore?: string;
|
||||
filters?: TPkiFilterRuleV2[];
|
||||
enabled?: boolean;
|
||||
channels?: Omit<TPkiAlertChannelV2, "id" | "createdAt" | "updatedAt">[];
|
||||
}
|
||||
|
||||
export interface TDeletePkiAlertV2 {
|
||||
alertId: string;
|
||||
}
|
||||
|
||||
export interface TGetPkiAlertV2MatchingCertificates {
|
||||
alertId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface TGetPkiAlertV2MatchingCertificatesResponse {
|
||||
certificates: TPkiCertificateMatchV2[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface TGetPkiAlertV2CurrentMatchingCertificates {
|
||||
projectId: string;
|
||||
filters: TPkiFilterRuleV2[];
|
||||
alertBefore: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface TGetPkiAlertV2CurrentMatchingCertificatesResponse {
|
||||
certificates: TPkiCertificateMatchV2[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export const pkiFilterRuleV2Schema = z.object({
|
||||
field: z.nativeEnum(PkiFilterFieldV2),
|
||||
operator: z.nativeEnum(PkiFilterOperatorV2),
|
||||
value: z.union([z.string(), z.array(z.string()), z.boolean()])
|
||||
});
|
||||
|
||||
const emailChannelConfigSchema = z.object({
|
||||
recipients: z
|
||||
.array(z.string())
|
||||
.transform((emails) => emails.filter(Boolean).map((email) => email.trim()))
|
||||
.refine((emails) => emails.length > 0, "At least one email recipient is required")
|
||||
.refine((emails) => emails.length <= 10, "Maximum 10 email recipients allowed")
|
||||
.refine(
|
||||
(emails) => emails.every((email) => z.string().email().safeParse(email).success),
|
||||
"All recipients must be valid email addresses"
|
||||
)
|
||||
});
|
||||
|
||||
export const pkiAlertChannelV2Schema = z.object({
|
||||
channelType: z.nativeEnum(PkiAlertChannelTypeV2),
|
||||
config: emailChannelConfigSchema,
|
||||
enabled: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export const createPkiAlertV2Schema = z.object({
|
||||
projectId: z.string().uuid(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Must be a valid name (lowercase, numbers, hyphens only)"),
|
||||
description: z.string().max(1000).optional(),
|
||||
eventType: z.nativeEnum(PkiAlertEventTypeV2),
|
||||
alertBefore: z
|
||||
.string()
|
||||
.regex(/^\d+[dwmy]$/, "Must be in format like '30d', '1w', '3m', '1y'")
|
||||
.refine((val) => val.length <= 32, "Alert timing too long")
|
||||
.optional(),
|
||||
filters: z.array(pkiFilterRuleV2Schema),
|
||||
enabled: z.boolean().default(true),
|
||||
channels: z.array(pkiAlertChannelV2Schema).min(1)
|
||||
});
|
||||
|
||||
export const updatePkiAlertV2Schema = createPkiAlertV2Schema.partial().omit({ projectId: true });
|
||||
@@ -54,6 +54,7 @@ export type TSecretApprovalRequest = {
|
||||
lastName: string;
|
||||
username: string;
|
||||
isOrgMembershipActive: boolean;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
project: string;
|
||||
environment: string;
|
||||
|
||||
@@ -109,6 +109,7 @@ export type SecretVersions = {
|
||||
actorType?: string | null;
|
||||
name?: string | null;
|
||||
membershipId?: string | null;
|
||||
groupId?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -86,6 +86,8 @@ export type ProjectWorkflowIntegrationConfig =
|
||||
accessRequestChannels: string;
|
||||
isSecretRequestNotificationEnabled: boolean;
|
||||
secretRequestChannels: string;
|
||||
isSecretSyncErrorNotificationEnabled: boolean;
|
||||
secretSyncErrorChannels: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
@@ -112,6 +114,8 @@ export type TUpdateProjectWorkflowIntegrationConfigDTO =
|
||||
accessRequestChannels: string;
|
||||
isSecretRequestNotificationEnabled: boolean;
|
||||
secretRequestChannels: string;
|
||||
isSecretSyncErrorNotificationEnabled: boolean;
|
||||
secretSyncErrorChannels: string;
|
||||
}
|
||||
| {
|
||||
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS;
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
|
||||
import { useListWorkspacePkiAlerts } from "@app/hooks/api";
|
||||
import { ProjectType } from "@app/hooks/api/projects/types";
|
||||
import { PkiAlertsV2Page } from "@app/views/PkiAlertsV2Page";
|
||||
|
||||
import { PkiAlertsSection } from "./components";
|
||||
import { PkiAlertsSection, PkiCollectionSection } from "./components";
|
||||
|
||||
export const AlertingPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentProject } = useProject();
|
||||
const [selectedTab, setSelectedTab] = useState("rule-based");
|
||||
|
||||
const { data: v1AlertsData } = useListWorkspacePkiAlerts({
|
||||
projectId: currentProject?.id || ""
|
||||
});
|
||||
|
||||
const hasV1Alerts = v1AlertsData?.alerts && v1AlertsData.alerts.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
|
||||
<Helmet>
|
||||
@@ -26,7 +38,33 @@ export const AlertingPage = () => {
|
||||
I={ProjectPermissionActions.Read}
|
||||
a={ProjectPermissionSub.PkiAlerts}
|
||||
>
|
||||
<PkiAlertsSection />
|
||||
{!hasV1Alerts ? (
|
||||
<div>
|
||||
<PkiAlertsV2Page hideContainer />
|
||||
</div>
|
||||
) : (
|
||||
<Tabs orientation="vertical" value={selectedTab} onValueChange={setSelectedTab}>
|
||||
<TabList>
|
||||
<Tab variant="project" value="rule-based">
|
||||
Certificate Alerts
|
||||
</Tab>
|
||||
<Tab variant="project" value="legacy">
|
||||
Collection Alerts (Legacy)
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel value="rule-based">
|
||||
<PkiAlertsV2Page />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="legacy">
|
||||
<div className="space-y-6">
|
||||
<PkiAlertsSection />
|
||||
<PkiCollectionSection />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faDownload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["certificateExport"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["certificateExport"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
onFormatSelected: (
|
||||
format: "pem" | "pkcs12",
|
||||
serialNumber: string,
|
||||
options?: ExportOptions
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type CertificateExportFormat = "pem" | "pkcs12";
|
||||
|
||||
export type ExportOptions = {
|
||||
pkcs12?: {
|
||||
password: string;
|
||||
alias: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelected }: Props) => {
|
||||
const [selectedFormat, setSelectedFormat] = useState<CertificateExportFormat>("pem");
|
||||
const [pkcs12Options, setPkcs12Options] = useState({
|
||||
password: "",
|
||||
alias: ""
|
||||
});
|
||||
|
||||
const serialNumber =
|
||||
(popUp?.certificateExport?.data as { serialNumber: string })?.serialNumber || "";
|
||||
|
||||
// Reset form whenever the modal opens
|
||||
useEffect(() => {
|
||||
if (popUp?.certificateExport?.isOpen) {
|
||||
setSelectedFormat("pem");
|
||||
setPkcs12Options({
|
||||
password: "",
|
||||
alias: ""
|
||||
});
|
||||
}
|
||||
}, [popUp?.certificateExport?.isOpen]);
|
||||
|
||||
const isFormValid = () => {
|
||||
if (selectedFormat === "pkcs12") {
|
||||
return pkcs12Options.password.length >= 6 && pkcs12Options.alias.trim() !== "";
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (serialNumber && isFormValid()) {
|
||||
const options: ExportOptions = {};
|
||||
|
||||
if (selectedFormat === "pkcs12") {
|
||||
options.pkcs12 = pkcs12Options;
|
||||
}
|
||||
|
||||
onFormatSelected(selectedFormat, serialNumber, options);
|
||||
handlePopUpToggle("certificateExport", false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.certificateExport?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("certificateExport", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Export Certificate">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">Choose the format for exporting your certificate</p>
|
||||
|
||||
<FormControl
|
||||
label="Export Format"
|
||||
helperText={
|
||||
selectedFormat === "pem"
|
||||
? "Privacy Enhanced Mail - Text-based certificate format"
|
||||
: "PKCS12 format - Binary keystore format compatible with Java applications"
|
||||
}
|
||||
>
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedFormat}
|
||||
onValueChange={(value) => setSelectedFormat(value as CertificateExportFormat)}
|
||||
>
|
||||
<SelectItem value="pem">PEM Format</SelectItem>
|
||||
<SelectItem value="pkcs12">PKCS12 Format</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{selectedFormat === "pkcs12" && (
|
||||
<>
|
||||
<FormControl
|
||||
label="Keystore Password"
|
||||
helperText={
|
||||
pkcs12Options.password.length > 0 && pkcs12Options.password.length < 6
|
||||
? undefined
|
||||
: "Password to protect the PKCS12 keystore (minimum 6 characters)"
|
||||
}
|
||||
isError={pkcs12Options.password.length > 0 && pkcs12Options.password.length < 6}
|
||||
errorText="Password must be at least 6 characters long"
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter keystore password"
|
||||
value={pkcs12Options.password}
|
||||
onChange={(e) =>
|
||||
setPkcs12Options((prev) => ({ ...prev, password: e.target.value }))
|
||||
}
|
||||
type="password"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Certificate Alias"
|
||||
helperText="Friendly name for the certificate in the keystore"
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter certificate alias"
|
||||
value={pkcs12Options.alias}
|
||||
onChange={(e) => setPkcs12Options((prev) => ({ ...prev, alias: e.target.value }))}
|
||||
/>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => handlePopUpToggle("certificateExport", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faDownload} />}
|
||||
onClick={handleExport}
|
||||
disabled={!serialNumber || !isFormValid()}
|
||||
>
|
||||
Export {selectedFormat.toUpperCase()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+9
-9
@@ -94,9 +94,9 @@ const createSchema = (shouldShowSubjectSection: boolean) => {
|
||||
export type FormData = z.infer<ReturnType<typeof createSchema>>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["certificateIssuance"]>;
|
||||
popUp: UsePopUpState<["issueCertificate"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["certificateIssuance"]>,
|
||||
popUpName: keyof UsePopUpState<["issueCertificate"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
profileId?: string;
|
||||
@@ -115,7 +115,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const inputSerialNumber =
|
||||
(popUp?.certificateIssuance?.data as { serialNumber: string })?.serialNumber || "";
|
||||
(popUp?.issueCertificate?.data as { serialNumber: string })?.serialNumber || "";
|
||||
const sanitizedSerialNumber = inputSerialNumber.replace(/[^a-fA-F0-9:]/g, "");
|
||||
|
||||
const { data: cert } = useGetCert(sanitizedSerialNumber);
|
||||
@@ -181,7 +181,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
} = useCertificateTemplate(
|
||||
templateData,
|
||||
actualSelectedProfile,
|
||||
popUp?.certificateIssuance?.isOpen || false,
|
||||
popUp?.issueCertificate?.isOpen || false,
|
||||
setValue,
|
||||
watch
|
||||
);
|
||||
@@ -227,10 +227,10 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
}, [cert, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (popUp?.certificateIssuance?.isOpen && profileId && !cert) {
|
||||
if (popUp?.issueCertificate?.isOpen && profileId && !cert) {
|
||||
setValue("profileId", profileId);
|
||||
}
|
||||
}, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]);
|
||||
}, [popUp?.issueCertificate?.isOpen, profileId, cert, setValue]);
|
||||
|
||||
const onFormSubmit = useCallback(
|
||||
async ({
|
||||
@@ -332,9 +332,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.certificateIssuance?.isOpen}
|
||||
isOpen={popUp?.issueCertificate?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("certificateIssuance", isOpen);
|
||||
handlePopUpToggle("issueCertificate", isOpen);
|
||||
if (!isOpen) {
|
||||
resetAllState();
|
||||
}
|
||||
@@ -503,7 +503,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => {
|
||||
handlePopUpToggle("certificateIssuance", false);
|
||||
handlePopUpToggle("issueCertificate", false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -75,6 +75,7 @@ export type FormData = z.infer<typeof schema>;
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["certificate"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificate"]>, state?: boolean) => void;
|
||||
preselectedTemplate?: { id: string; name: string };
|
||||
};
|
||||
|
||||
type TCertificateDetails = {
|
||||
@@ -86,7 +87,7 @@ type TCertificateDetails = {
|
||||
|
||||
const CERT_TEMPLATE_NONE_VALUE = "none";
|
||||
|
||||
export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
export const CertificateModal = ({ popUp, handlePopUpToggle, preselectedTemplate }: Props) => {
|
||||
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
|
||||
const { currentProject } = useProject();
|
||||
const { data: cert } = useGetCert(
|
||||
@@ -147,13 +148,15 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
(cert.extendedKeyUsages || []).map((name) => [name, true])
|
||||
)
|
||||
});
|
||||
} else {
|
||||
} else if (popUp?.certificate?.isOpen) {
|
||||
const templateId = preselectedTemplate?.id || CERT_TEMPLATE_NONE_VALUE;
|
||||
|
||||
reset({
|
||||
caId: "",
|
||||
commonName: "",
|
||||
subjectAltNames: "",
|
||||
ttl: "",
|
||||
certificateTemplateId: CERT_TEMPLATE_NONE_VALUE,
|
||||
certificateTemplateId: templateId,
|
||||
keyUsages: {
|
||||
[CertKeyUsage.DIGITAL_SIGNATURE]: true,
|
||||
[CertKeyUsage.KEY_ENCIPHERMENT]: true
|
||||
@@ -161,7 +164,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
extendedKeyUsages: {}
|
||||
});
|
||||
}
|
||||
}, [cert]);
|
||||
}, [cert, preselectedTemplate, popUp?.certificate?.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cert && selectedCertTemplate) {
|
||||
@@ -198,10 +201,14 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
ttl,
|
||||
keyUsages: Object.entries(keyUsages)
|
||||
.filter(([, value]) => value)
|
||||
.map(([key]) => key as CertKeyUsage),
|
||||
.map(([key]) =>
|
||||
key === CertKeyUsage.CRL_SIGN
|
||||
? "cRLSign"
|
||||
: key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
),
|
||||
extendedKeyUsages: Object.entries(extendedKeyUsages)
|
||||
.filter(([, value]) => value)
|
||||
.map(([key]) => key as CertExtendedKeyUsage)
|
||||
.map(([key]) => key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()))
|
||||
});
|
||||
|
||||
reset();
|
||||
@@ -269,15 +276,25 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
isRequired
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
isDisabled={Boolean(cert)}
|
||||
isDisabled={Boolean(cert) || Boolean(preselectedTemplate)}
|
||||
>
|
||||
<SelectItem value={CERT_TEMPLATE_NONE_VALUE} key="cert-template-none">
|
||||
None
|
||||
</SelectItem>
|
||||
{preselectedTemplate &&
|
||||
!templatesData?.certificateTemplates?.find(
|
||||
(t) => t.id === preselectedTemplate.id
|
||||
) && (
|
||||
<SelectItem
|
||||
value={preselectedTemplate.id}
|
||||
key={`cert-template-preselected-${preselectedTemplate.id}`}
|
||||
>
|
||||
{preselectedTemplate.name}
|
||||
</SelectItem>
|
||||
)}
|
||||
{(templatesData?.certificateTemplates || []).map(({ id, name }) => (
|
||||
<SelectItem value={id} key={`cert-template-${id}`}>
|
||||
{name}
|
||||
|
||||
+51
-15
@@ -9,15 +9,15 @@ import {
|
||||
ProjectPermissionSub,
|
||||
useProject
|
||||
} from "@app/context";
|
||||
import { useDeleteCert } from "@app/hooks/api";
|
||||
import { useDeleteCert, useDownloadCertPkcs12 } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { CertificateCertModal } from "./CertificateCertModal";
|
||||
import { CertificateExportModal, ExportOptions } from "./CertificateExportModal";
|
||||
import { CertificateImportModal } from "./CertificateImportModal";
|
||||
import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
|
||||
import { CertificateManagePkiSyncsModal } from "./CertificateManagePkiSyncsModal";
|
||||
import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal";
|
||||
import { CertificateModal } from "./CertificateModal";
|
||||
import { CertificateRenewalModal } from "./CertificateRenewalModal";
|
||||
import { CertificateRevocationModal } from "./CertificateRevocationModal";
|
||||
import { CertificatesTable } from "./CertificatesTable";
|
||||
@@ -25,15 +25,13 @@ import { CertificatesTable } from "./CertificatesTable";
|
||||
export const CertificatesSection = () => {
|
||||
const { currentProject } = useProject();
|
||||
const { mutateAsync: deleteCert } = useDeleteCert();
|
||||
|
||||
// TODO: Use subscription.pkiLegacyTemplates to block legacy templates creation
|
||||
const isLegacyTemplatesEnabled = true;
|
||||
const { mutateAsync: downloadCertPkcs12 } = useDownloadCertPkcs12();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"certificateIssuance",
|
||||
"certificate",
|
||||
"issueCertificate",
|
||||
"certificateImport",
|
||||
"certificateCert",
|
||||
"certificateExport",
|
||||
"deleteCertificate",
|
||||
"revokeCertificate",
|
||||
"manageRenewal",
|
||||
@@ -54,6 +52,45 @@ export const CertificatesSection = () => {
|
||||
handlePopUpClose("deleteCertificate");
|
||||
};
|
||||
|
||||
const handleCertificateExport = async (
|
||||
format: "pem" | "pkcs12",
|
||||
serialNumber: string,
|
||||
options?: ExportOptions
|
||||
) => {
|
||||
if (format === "pem") {
|
||||
handlePopUpOpen("certificateCert", { serialNumber });
|
||||
} else if (format === "pkcs12") {
|
||||
if (!currentProject?.slug) return;
|
||||
|
||||
if (!options?.pkcs12?.password || !options?.pkcs12?.alias) {
|
||||
createNotification({
|
||||
text: "Password and alias are required for PKCS12 export",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadCertPkcs12({
|
||||
serialNumber,
|
||||
projectSlug: currentProject.slug,
|
||||
password: options.pkcs12.password,
|
||||
alias: options.pkcs12.alias
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "PKCS12 certificate downloaded successfully",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error: any) {
|
||||
createNotification({
|
||||
text: error?.message || "Failed to download PKCS12 certificate",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
@@ -76,9 +113,7 @@ export const CertificatesSection = () => {
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
handlePopUpOpen(isLegacyTemplatesEnabled ? "certificate" : "certificateIssuance")
|
||||
}
|
||||
onClick={() => handlePopUpOpen("issueCertificate")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Issue
|
||||
@@ -88,13 +123,14 @@ export const CertificatesSection = () => {
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
|
||||
{isLegacyTemplatesEnabled ? (
|
||||
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
) : (
|
||||
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
)}
|
||||
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateExportModal
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
onFormatSelected={handleCertificateExport}
|
||||
/>
|
||||
<CertificateManageRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
|
||||
@@ -64,10 +64,11 @@ type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
[
|
||||
"certificate",
|
||||
"issueCertificate",
|
||||
"deleteCertificate",
|
||||
"revokeCertificate",
|
||||
"certificateCert",
|
||||
"certificateExport",
|
||||
"manageRenewal",
|
||||
"renewCertificate",
|
||||
"managePkiSyncs"
|
||||
@@ -275,7 +276,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () =>
|
||||
handlePopUpOpen("certificateCert", {
|
||||
handlePopUpOpen("certificateExport", {
|
||||
serialNumber: certificate.serialNumber
|
||||
})
|
||||
}
|
||||
@@ -297,7 +298,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () =>
|
||||
handlePopUpOpen("certificate", {
|
||||
handlePopUpOpen("issueCertificate", {
|
||||
serialNumber: certificate.serialNumber
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
faCertificate,
|
||||
faCog,
|
||||
faEllipsis,
|
||||
faFileContract,
|
||||
faPencil,
|
||||
faPlus,
|
||||
faTrash
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionPkiTemplateActions,
|
||||
ProjectPermissionSub,
|
||||
useProject,
|
||||
@@ -50,6 +52,7 @@ import { useDeleteCertTemplateV2 } from "@app/hooks/api";
|
||||
import { useListCertificateTemplates } from "@app/hooks/api/certificateTemplates/queries";
|
||||
import { ProjectType } from "@app/hooks/api/projects/types";
|
||||
|
||||
import { CertificateModal } from "../CertificatesPage/components/CertificateModal";
|
||||
import { CertificateTemplateEnrollmentModal } from "../CertificatesPage/components/CertificateTemplateEnrollmentModal";
|
||||
import { PkiTemplateForm } from "./components/PkiTemplateForm";
|
||||
|
||||
@@ -64,7 +67,8 @@ export const PkiTemplateListPage = () => {
|
||||
"certificateTemplate",
|
||||
"deleteTemplate",
|
||||
"enrollmentOptions",
|
||||
"estUpgradePlan"
|
||||
"estUpgradePlan",
|
||||
"certificateFromTemplate"
|
||||
] as const);
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
@@ -160,6 +164,27 @@ export const PkiTemplateListPage = () => {
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Create}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("certificateFromTemplate", template);
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faFileContract} />}
|
||||
>
|
||||
Issue Certificate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionPkiTemplateActions.Edit}
|
||||
a={ProjectPermissionSub.CertificateTemplates}
|
||||
@@ -284,6 +309,16 @@ export const PkiTemplateListPage = () => {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<CertificateTemplateEnrollmentModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateModal
|
||||
popUp={{
|
||||
certificate: {
|
||||
isOpen: popUp.certificateFromTemplate.isOpen,
|
||||
data: popUp.certificateFromTemplate.data
|
||||
}
|
||||
}}
|
||||
handlePopUpToggle={(_, state) => handlePopUpToggle("certificateFromTemplate", state)}
|
||||
preselectedTemplate={popUp.certificateFromTemplate.data}
|
||||
/>
|
||||
</div>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.estUpgradePlan.isOpen}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ProjectType } from "@app/hooks/api/projects/types";
|
||||
import { CertificateProfilesTab } from "./components/CertificateProfilesTab";
|
||||
import { CertificatesTab } from "./components/CertificatesTab";
|
||||
import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab";
|
||||
import { PkiCollectionsTab } from "./components/PkiCollectionsTab";
|
||||
|
||||
enum TabSections {
|
||||
CertificateProfiles = "profiles",
|
||||
@@ -54,9 +53,6 @@ export const PoliciesPage = () => {
|
||||
<Tab variant="project" value={TabSections.Certificates}>
|
||||
Certificates
|
||||
</Tab>
|
||||
<Tab variant="project" value={TabSections.PkiCollections}>
|
||||
Certificate Collections
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel value={TabSections.CertificateProfiles}>
|
||||
@@ -70,10 +66,6 @@ export const PoliciesPage = () => {
|
||||
<TabPanel value={TabSections.Certificates}>
|
||||
<CertificatesTab />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={TabSections.PkiCollections}>
|
||||
<PkiCollectionsTab />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+24
-1
@@ -16,6 +16,7 @@ import {
|
||||
|
||||
import { CreateProfileModal } from "./CreateProfileModal";
|
||||
import { ProfileList } from "./ProfileList";
|
||||
import { RevealAcmeEabSecretModal } from "./RevealAcmeEabSecretModal";
|
||||
|
||||
export const CertificateProfilesTab = () => {
|
||||
const { permission } = useProjectPermission();
|
||||
@@ -23,6 +24,8 @@ export const CertificateProfilesTab = () => {
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isRevealProfileAcmeEabSecretModalOpen, setIsRevealProfileAcmeEabSecretModalOpen] =
|
||||
useState(false);
|
||||
const [selectedProfile, setSelectedProfile] = useState<TCertificateProfileWithDetails | null>(
|
||||
null
|
||||
);
|
||||
@@ -43,6 +46,11 @@ export const CertificateProfilesTab = () => {
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleRevealProfileAcmeEabSecret = (profile: TCertificateProfileWithDetails) => {
|
||||
setSelectedProfile(profile);
|
||||
setIsRevealProfileAcmeEabSecretModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteProfile = (profile: TCertificateProfileWithDetails) => {
|
||||
setSelectedProfile(profile);
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -85,7 +93,11 @@ export const CertificateProfilesTab = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProfileList onEditProfile={handleEditProfile} onDeleteProfile={handleDeleteProfile} />
|
||||
<ProfileList
|
||||
onEditProfile={handleEditProfile}
|
||||
onRevealProfileAcmeEabSecret={handleRevealProfileAcmeEabSecret}
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
/>
|
||||
|
||||
<CreateProfileModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
|
||||
|
||||
@@ -101,6 +113,17 @@ export const CertificateProfilesTab = () => {
|
||||
mode="edit"
|
||||
/>
|
||||
|
||||
{selectedProfile.enrollmentType === "acme" && (
|
||||
<RevealAcmeEabSecretModal
|
||||
isOpen={isRevealProfileAcmeEabSecretModalOpen}
|
||||
onClose={() => {
|
||||
setIsRevealProfileAcmeEabSecretModalOpen(false);
|
||||
setSelectedProfile(null);
|
||||
}}
|
||||
profile={selectedProfile}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title={`Delete Certificate Profile ${selectedProfile.slug}?`}
|
||||
|
||||
+54
-11
@@ -18,6 +18,7 @@ import {
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { envConfig } from "@app/config/env";
|
||||
import { useProject } from "@app/context";
|
||||
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
|
||||
import {
|
||||
@@ -45,7 +46,7 @@ const createSchema = z
|
||||
.trim()
|
||||
.max(1000, "Description must be less than 1000 characters")
|
||||
.optional(),
|
||||
enrollmentType: z.enum(["api", "est"]),
|
||||
enrollmentType: z.enum(["api", "est", "acme"]),
|
||||
certificateAuthorityId: z.string().min(1, "Certificate Authority is required"),
|
||||
certificateTemplateId: z.string().min(1, "Certificate Template is required"),
|
||||
estConfig: z
|
||||
@@ -72,7 +73,8 @@ const createSchema = z
|
||||
autoRenew: z.boolean().optional(),
|
||||
renewBeforeDays: z.number().min(1).max(365).optional()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
acmeConfig: z.object({}).optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -82,6 +84,9 @@ const createSchema = z
|
||||
if (data.enrollmentType === "api" && !data.apiConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.enrollmentType === "acme" && !data.acmeConfig) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
@@ -105,7 +110,7 @@ const editSchema = z
|
||||
.trim()
|
||||
.max(1000, "Description must be less than 1000 characters")
|
||||
.optional(),
|
||||
enrollmentType: z.enum(["api", "est"]),
|
||||
enrollmentType: z.enum(["api", "est", "acme"]),
|
||||
certificateAuthorityId: z.string().optional(),
|
||||
certificateTemplateId: z.string().optional(),
|
||||
estConfig: z
|
||||
@@ -120,7 +125,8 @@ const editSchema = z
|
||||
autoRenew: z.boolean().optional(),
|
||||
renewBeforeDays: z.number().min(1).max(365).optional()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
acmeConfig: z.object({}).optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -130,6 +136,9 @@ const editSchema = z
|
||||
if (data.enrollmentType === "api" && !data.apiConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.enrollmentType === "acme" && !data.acmeConfig) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
@@ -188,7 +197,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
autoRenew: profile.apiConfig?.autoRenew || false,
|
||||
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
acmeConfig: profile.enrollmentType === "acme" ? {} : undefined
|
||||
}
|
||||
: {
|
||||
slug: "",
|
||||
@@ -199,7 +209,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
apiConfig: {
|
||||
autoRenew: false,
|
||||
renewBeforeDays: 30
|
||||
}
|
||||
},
|
||||
acmeConfig: {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -230,7 +241,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
autoRenew: profile.apiConfig?.autoRenew || false,
|
||||
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
acmeConfig: profile.enrollmentType === "acme" ? {} : undefined
|
||||
});
|
||||
}
|
||||
}, [isEdit, profile, reset]);
|
||||
@@ -249,6 +261,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
updateData.estConfig = data.estConfig;
|
||||
} else if (data.enrollmentType === "api" && data.apiConfig) {
|
||||
updateData.apiConfig = data.apiConfig;
|
||||
} else if (data.enrollmentType === "acme" && data.acmeConfig) {
|
||||
updateData.acmeConfig = data.acmeConfig;
|
||||
}
|
||||
|
||||
await updateProfile.mutateAsync(updateData);
|
||||
@@ -274,6 +288,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
};
|
||||
} else if (data.enrollmentType === "api" && data.apiConfig) {
|
||||
createData.apiConfig = data.apiConfig;
|
||||
} else if (data.enrollmentType === "acme" && data.acmeConfig) {
|
||||
createData.acmeConfig = data.acmeConfig;
|
||||
}
|
||||
|
||||
await createProfile.mutateAsync(createData);
|
||||
@@ -376,17 +392,23 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
if (watchedEnrollmentType === "est") {
|
||||
setValue("apiConfig", undefined);
|
||||
setValue("estConfig", {
|
||||
disableBootstrapCaValidation: false,
|
||||
passphrase: ""
|
||||
});
|
||||
setValue("apiConfig", undefined);
|
||||
} else {
|
||||
setValue("acmeConfig", undefined);
|
||||
} else if (watchedEnrollmentType === "api") {
|
||||
setValue("apiConfig", {
|
||||
autoRenew: false,
|
||||
renewBeforeDays: 30
|
||||
});
|
||||
setValue("estConfig", undefined);
|
||||
setValue("acmeConfig", undefined);
|
||||
} else if (watchedEnrollmentType === "acme") {
|
||||
setValue("estConfig", undefined);
|
||||
setValue("apiConfig", undefined);
|
||||
setValue("acmeConfig", {});
|
||||
}
|
||||
onChange(value);
|
||||
}}
|
||||
@@ -424,12 +446,18 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
disableBootstrapCaValidation: false,
|
||||
passphrase: ""
|
||||
});
|
||||
} else {
|
||||
setValue("estConfig", undefined);
|
||||
setValue("acmeConfig", undefined);
|
||||
} else if (value === "api") {
|
||||
setValue("apiConfig", {
|
||||
autoRenew: false,
|
||||
renewBeforeDays: 30
|
||||
});
|
||||
setValue("estConfig", undefined);
|
||||
setValue("acmeConfig", undefined);
|
||||
} else if (value === "acme") {
|
||||
setValue("apiConfig", undefined);
|
||||
setValue("estConfig", undefined);
|
||||
setValue("acmeConfig", {});
|
||||
}
|
||||
onChange(value);
|
||||
}}
|
||||
@@ -439,6 +467,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
>
|
||||
<SelectItem value="api">API</SelectItem>
|
||||
<SelectItem value="est">EST</SelectItem>
|
||||
{envConfig.ACME_FEATURE_ENABLED && <SelectItem value="acme">ACME</SelectItem>}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -548,6 +577,20 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ACME Configuration */}
|
||||
{watchedEnrollmentType === "acme" && (
|
||||
<div className="mb-4 space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="acmeConfig"
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<div className="flex items-center gap-2">{/* FIXME: ACME configuration */}</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{watchedAutoRenew && (
|
||||
<div className="mb-4 space-y-4">
|
||||
<Controller
|
||||
|
||||
+7
-1
@@ -20,9 +20,14 @@ import { ProfileRow } from "./ProfileRow";
|
||||
interface Props {
|
||||
onEditProfile: (profile: TCertificateProfileWithDetails) => void;
|
||||
onDeleteProfile: (profile: TCertificateProfileWithDetails) => void;
|
||||
onRevealProfileAcmeEabSecret: (profile: TCertificateProfileWithDetails) => void;
|
||||
}
|
||||
|
||||
export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
|
||||
export const ProfileList = ({
|
||||
onEditProfile,
|
||||
onRevealProfileAcmeEabSecret,
|
||||
onDeleteProfile
|
||||
}: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { data, isLoading } = useListCertificateProfiles({
|
||||
@@ -88,6 +93,7 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
onEditProfile={onEditProfile}
|
||||
onRevealProfileAcmeEabSecret={onRevealProfileAcmeEabSecret}
|
||||
onDeleteProfile={onDeleteProfile}
|
||||
/>
|
||||
))}
|
||||
|
||||
+32
-8
@@ -5,6 +5,7 @@ import {
|
||||
faCopy,
|
||||
faEdit,
|
||||
faEllipsis,
|
||||
faEye,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -36,15 +37,21 @@ import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPa
|
||||
interface Props {
|
||||
profile: TCertificateProfile;
|
||||
onEditProfile: (profile: TCertificateProfile) => void;
|
||||
onRevealProfileAcmeEabSecret: (profile: TCertificateProfile) => void;
|
||||
onDeleteProfile: (profile: TCertificateProfile) => void;
|
||||
}
|
||||
|
||||
export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) => {
|
||||
export const ProfileRow = ({
|
||||
profile,
|
||||
onEditProfile,
|
||||
onRevealProfileAcmeEabSecret,
|
||||
onDeleteProfile
|
||||
}: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const { data: caData } = useGetCaById(profile.caId);
|
||||
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["certificateIssuance"] as const);
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["issueCertificate"] as const);
|
||||
|
||||
const [isIdCopied, setIsIdCopied] = useToggle(false);
|
||||
|
||||
@@ -69,6 +76,11 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
const canRevealProfileAcmeEabSecret = permission.can(
|
||||
ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret,
|
||||
ProjectPermissionSub.CertificateProfiles
|
||||
);
|
||||
|
||||
const canIssueCertificate = permission.can(
|
||||
ProjectPermissionCertificateProfileActions.IssueCert,
|
||||
ProjectPermissionSub.CertificateProfiles
|
||||
@@ -82,7 +94,8 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
|
||||
const getEnrollmentTypeBadge = (enrollmentType: string) => {
|
||||
const config = {
|
||||
api: { variant: "ghost" as const, label: "API" },
|
||||
est: { variant: "ghost" as const, label: "EST" }
|
||||
est: { variant: "ghost" as const, label: "EST" },
|
||||
acme: { variant: "ghost" as const, label: "ACME" }
|
||||
} as const;
|
||||
|
||||
const configKey = Object.keys(config).includes(enrollmentType)
|
||||
@@ -127,7 +140,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
|
||||
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} className="w-3" />}
|
||||
onClick={() => handleCopyId()}
|
||||
>
|
||||
Copy Profile ID
|
||||
@@ -138,18 +151,29 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
|
||||
e.stopPropagation();
|
||||
onEditProfile(profile);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
icon={<FontAwesomeIcon icon={faEdit} className="w-3" />}
|
||||
>
|
||||
Edit Profile
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canRevealProfileAcmeEabSecret && profile.enrollmentType === "acme" && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRevealProfileAcmeEabSecret(profile);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faEye} className="w-3" />}
|
||||
>
|
||||
Reveal ACME EAB
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canIssueCertificate && profile.enrollmentType === "api" && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpToggle("certificateIssuance");
|
||||
handlePopUpToggle("issueCertificate");
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faPlus} />}
|
||||
icon={<FontAwesomeIcon icon={faPlus} className="w-3" />}
|
||||
>
|
||||
Issue Certificate
|
||||
</DropdownMenuItem>
|
||||
@@ -160,7 +184,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
|
||||
e.stopPropagation();
|
||||
onDeleteProfile(profile);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
icon={<FontAwesomeIcon icon={faTrash} className="w-3" />}
|
||||
>
|
||||
Delete Profile
|
||||
</DropdownMenuItem>
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Spinner
|
||||
} from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { TCertificateProfileWithDetails } from "@app/hooks/api/certificateProfiles";
|
||||
import { useRevealAcmeEabSecret } from "@app/hooks/api/certificateProfiles/queries";
|
||||
|
||||
const RESET_COPIED_DELAY = 1 * 1000;
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: TCertificateProfileWithDetails;
|
||||
};
|
||||
|
||||
export const RevealAcmeEabSecretModal = ({ isOpen, onClose, profile }: Props) => {
|
||||
const [isAcmeDirectoryUrlCopied, setIsAcmeDirectoryUrlCopied] = useToggle(false);
|
||||
const [isEabKidCopied, setIsEabKidCopied] = useToggle(false);
|
||||
const [isEabSecretCopied, setIsEabSecretCopied] = useToggle(false);
|
||||
const revealAcmeEabSecret = useRevealAcmeEabSecret({ profileId: profile.id });
|
||||
const { data, isLoading, isError, error } = revealAcmeEabSecret;
|
||||
const { directoryUrl } = profile.acmeConfig!;
|
||||
const { eabKid, eabSecret } = data ?? { eabKid: "", eabSecret: "" };
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title="Reveal ACME EAB"
|
||||
subTitle="To issue certificates automatically, your ACME client needs the following details."
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<Alert variant="danger">
|
||||
<AlertDescription>Failed to reveal EAB secret: {error.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{data && (
|
||||
<>
|
||||
<FormLabel
|
||||
label="ACME Directory URL"
|
||||
tooltipText="The ACME directory URL for your ACME client to issue certificates."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input value={directoryUrl} disabled />
|
||||
<IconButton
|
||||
ariaLabel="copy"
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(directoryUrl);
|
||||
setIsAcmeDirectoryUrlCopied.on();
|
||||
setTimeout(() => {
|
||||
setIsAcmeDirectoryUrlCopied.off();
|
||||
}, RESET_COPIED_DELAY);
|
||||
}}
|
||||
className="w-10"
|
||||
>
|
||||
<FontAwesomeIcon icon={isAcmeDirectoryUrlCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<FormLabel
|
||||
label="EAB KID"
|
||||
className="mt-4"
|
||||
tooltipText="The EAB Key Identifier (KID) for your ACME client to authenticate when registering a new account."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input value={eabKid} disabled />
|
||||
<IconButton
|
||||
ariaLabel="copy"
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(eabKid);
|
||||
setIsEabKidCopied.on();
|
||||
setTimeout(() => {
|
||||
setIsEabKidCopied.off();
|
||||
}, RESET_COPIED_DELAY);
|
||||
}}
|
||||
className="w-10"
|
||||
>
|
||||
<FontAwesomeIcon icon={isEabKidCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<FormLabel
|
||||
label="EAB Secret"
|
||||
className="mt-4"
|
||||
tooltipText="The EAB Secret for your ACME client to authenticate when registering a new account."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input value={eabSecret} isDisabled />
|
||||
<IconButton
|
||||
ariaLabel="copy"
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(eabSecret);
|
||||
setIsEabSecretCopied.on();
|
||||
setTimeout(() => {
|
||||
setIsEabSecretCopied.off();
|
||||
}, RESET_COPIED_DELAY);
|
||||
}}
|
||||
className="w-10"
|
||||
>
|
||||
<FontAwesomeIcon icon={isEabSecretCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+4
-1
@@ -251,7 +251,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
|
||||
|
||||
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
defaultValues: getDefaultValues()
|
||||
defaultValues: getDefaultValues(),
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
criteriaMode: "all"
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+103
-4
@@ -6,7 +6,15 @@ import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose,
|
||||
SecretInput,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
APP_CONNECTION_MAP,
|
||||
getAppConnectionMethodDetails,
|
||||
@@ -27,10 +35,13 @@ import {
|
||||
} from "./GenericAppConnectionFields";
|
||||
|
||||
type ClientSecretForm = z.infer<typeof clientSecretSchema>;
|
||||
type CertificateForm = z.infer<typeof certificateSchema>;
|
||||
|
||||
type TInputFormData = ClientSecretForm | CertificateForm;
|
||||
|
||||
type Props = {
|
||||
appConnection?: TAzureClientSecretsConnection;
|
||||
onSubmit: (formData: ClientSecretForm) => Promise<void>;
|
||||
onSubmit: (formData: TInputFormData) => Promise<void>;
|
||||
projectId: string | undefined | null;
|
||||
};
|
||||
|
||||
@@ -53,7 +64,21 @@ const clientSecretSchema = baseSchema.extend({
|
||||
})
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [oauthSchema, clientSecretSchema]);
|
||||
const certificateSchema = baseSchema.extend({
|
||||
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
|
||||
credentials: z.object({
|
||||
clientId: z.string().trim().min(1, "Client ID is required"),
|
||||
certificateBody: z.string().trim().min(1, "Certificate is required"),
|
||||
privateKey: z.string().trim().min(1, "Private Key is required"),
|
||||
tenantId: z.string().trim().min(1, "Tenant ID is required")
|
||||
})
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [
|
||||
oauthSchema,
|
||||
clientSecretSchema,
|
||||
certificateSchema
|
||||
]);
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -96,6 +121,20 @@ const getDefaultValues = (appConnection?: TAzureClientSecretsConnection): Partia
|
||||
};
|
||||
}
|
||||
break;
|
||||
case AzureClientSecretsConnectionMethod.Certificate:
|
||||
if ("clientId" in credentials && "tenantId" in credentials) {
|
||||
return {
|
||||
...base,
|
||||
method: AzureClientSecretsConnectionMethod.Certificate,
|
||||
credentials: {
|
||||
clientId: credentials.clientId,
|
||||
tenantId: credentials.tenantId,
|
||||
certificateBody: "",
|
||||
privateKey: ""
|
||||
}
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
@@ -152,6 +191,9 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit, proj
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
||||
await onSubmit(formData);
|
||||
break;
|
||||
case AzureClientSecretsConnectionMethod.Certificate:
|
||||
await onSubmit(formData);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
|
||||
}
|
||||
@@ -207,7 +249,11 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit, proj
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="tenantId"
|
||||
name={
|
||||
selectedMethod === AzureClientSecretsConnectionMethod.OAuth
|
||||
? "tenantId"
|
||||
: "credentials.tenantId"
|
||||
}
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
@@ -264,6 +310,59 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit, proj
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedMethod === AzureClientSecretsConnectionMethod.Certificate && (
|
||||
<>
|
||||
<Controller
|
||||
name="credentials.clientId"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Client ID"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="00000000-0000-0000-0000-000000000000" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.certificateBody"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Certificate"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<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)}
|
||||
placeholder="-----BEGIN CERTIFICATE-----..."
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.privateKey"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Private Key"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<SecretInput
|
||||
placeholder="-----BEGIN PRIVATE KEY-----..."
|
||||
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"
|
||||
|
||||
+7
-4
@@ -80,7 +80,7 @@ export const GatewayCliDeploymentMethod = () => {
|
||||
const [relay, setRelay] = useState<null | {
|
||||
id: string;
|
||||
name: string;
|
||||
}>(null);
|
||||
}>({ id: "_auto", name: "Auto Select Relay" });
|
||||
const [identity, setIdentity] = useState<null | {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -183,9 +183,8 @@ export const GatewayCliDeploymentMethod = () => {
|
||||
};
|
||||
|
||||
const command = useMemo(() => {
|
||||
return `infisical gateway start --name=${name} --relay=${
|
||||
relay?.name || ""
|
||||
} --domain=${siteURL} --token=${identityToken}`;
|
||||
const relayPart = relay?.id !== "_auto" ? ` --relay=${relay?.name || ""}` : "";
|
||||
return `sudo infisical gateway start --name=${name}${relayPart} --domain=${siteURL} --token=${identityToken}`;
|
||||
}, [name, relay, identityToken, siteURL]);
|
||||
|
||||
if (step === "command") {
|
||||
@@ -256,6 +255,10 @@ export const GatewayCliDeploymentMethod = () => {
|
||||
}}
|
||||
isLoading={isRelaysLoading}
|
||||
options={[
|
||||
{
|
||||
id: "_auto",
|
||||
name: "Auto Select Relay"
|
||||
},
|
||||
{
|
||||
id: "_create",
|
||||
name: "Deploy New Relay"
|
||||
|
||||
+11
-3
@@ -1,6 +1,6 @@
|
||||
import { components, OptionProps } from "react-select";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faPlus, faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
export const RelayOption = ({
|
||||
@@ -9,16 +9,24 @@ export const RelayOption = ({
|
||||
...props
|
||||
}: OptionProps<{ id: string; name: string }>) => {
|
||||
const isCreateOption = props.data.id === "_create";
|
||||
const isAutoOption = props.data.id === "_auto";
|
||||
|
||||
return (
|
||||
<components.Option isSelected={isSelected} {...props}>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
{isCreateOption ? (
|
||||
{isCreateOption && (
|
||||
<div className="flex items-center gap-x-1 text-mineshaft-400">
|
||||
<FontAwesomeIcon icon={faPlus} size="sm" />
|
||||
<span className="mr-auto">Deploy New Relay</span>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{isAutoOption && (
|
||||
<div className="flex items-center gap-x-1 text-mineshaft-400">
|
||||
<FontAwesomeIcon icon={faWandMagicSparkles} size="sm" />
|
||||
<span className="mr-auto">Auto Select Relay</span>
|
||||
</div>
|
||||
)}
|
||||
{!isCreateOption && !isAutoOption && (
|
||||
<>
|
||||
<p className="truncate">{children}</p>
|
||||
{isSelected && (
|
||||
|
||||
@@ -28,7 +28,8 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => {
|
||||
// We are temporarily showing these resources so that we can gauge interest before committing
|
||||
{ name: "RDP", resource: PamResourceType.RDP },
|
||||
{ name: "SSH", resource: PamResourceType.SSH },
|
||||
{ name: "Kubernetes", resource: PamResourceType.Kubernetes }
|
||||
{ name: "Kubernetes", resource: PamResourceType.Kubernetes },
|
||||
{ name: "MCP", resource: PamResourceType.MCP }
|
||||
];
|
||||
}, [resourceOptions]);
|
||||
|
||||
@@ -65,7 +66,8 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => {
|
||||
if (
|
||||
resource === PamResourceType.RDP ||
|
||||
resource === PamResourceType.SSH ||
|
||||
resource === PamResourceType.Kubernetes
|
||||
resource === PamResourceType.Kubernetes ||
|
||||
resource === PamResourceType.MCP
|
||||
) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
text: "Your current plan does not include access to this resource type. To unlock this feature, please upgrade to Infisical Enterprise plan.",
|
||||
|
||||
+18
-1
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ enum GroupMembersOrderBy {
|
||||
}
|
||||
|
||||
export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
@@ -62,6 +64,21 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
|
||||
initPerPage: getUserTablePreference("projectGroupMembersTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
// this handles links from secret versions when the actor is in a group membership
|
||||
const { username, ...restSearch } = useSearch({
|
||||
strict: false
|
||||
});
|
||||
useEffect(() => {
|
||||
if (username) {
|
||||
setSearch(username);
|
||||
navigate({
|
||||
to: ".",
|
||||
replace: true,
|
||||
search: restSearch
|
||||
});
|
||||
}
|
||||
}, [username]);
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["assumePrivileges"] as const);
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ProjectAccessControlTabs } from "@app/types/project";
|
||||
|
||||
@@ -8,6 +10,11 @@ export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/groups/$groupId"
|
||||
)({
|
||||
component: GroupDetailsByIDPage,
|
||||
validateSearch: zodValidator(
|
||||
z.object({
|
||||
username: z.string().optional().catch(undefined)
|
||||
})
|
||||
),
|
||||
beforeLoad: ({ context, params }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
|
||||
+15
@@ -249,6 +249,21 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
tooltipText={
|
||||
<div>
|
||||
You can add references to other secrets using the format{" "}
|
||||
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">
|
||||
${"{"}secret_name{"}"}
|
||||
</code>
|
||||
<br />
|
||||
<br />
|
||||
You can go to the referenced secret by holding the{" "}
|
||||
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">Cmd</code> (Mac) or{" "}
|
||||
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">Ctrl</code>{" "}
|
||||
(Windows/Linux) key and clicking on the secret name.
|
||||
</div>
|
||||
}
|
||||
tooltipClassName="max-w-md"
|
||||
label="Value"
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
|
||||
+7
-3
@@ -160,14 +160,18 @@ export const SecretApprovalRequestChanges = ({ approvalRequestId, onGoBack }: Pr
|
||||
secretApprovalRequestDetails.policy.bypassers.some(({ userId }) => userId === userSession.id);
|
||||
|
||||
const reviewedUsers = secretApprovalRequestDetails?.reviewers?.reduce<
|
||||
Record<string, { status: ApprovalStatus; comment: string; isOrgMembershipActive: boolean }>
|
||||
Record<
|
||||
string,
|
||||
{ status: ApprovalStatus; comment: string; isOrgMembershipActive: boolean; createdAt: Date }
|
||||
>
|
||||
>(
|
||||
(prev, curr) => ({
|
||||
...prev,
|
||||
[curr.userId]: {
|
||||
status: curr.status,
|
||||
comment: curr.comment,
|
||||
isOrgMembershipActive: curr.isOrgMembershipActive
|
||||
isOrgMembershipActive: curr.isOrgMembershipActive,
|
||||
createdAt: curr.createdAt
|
||||
}
|
||||
}),
|
||||
{}
|
||||
@@ -488,7 +492,7 @@ export const SecretApprovalRequestChanges = ({ approvalRequestId, onGoBack }: Pr
|
||||
</span>{" "}
|
||||
the request on{" "}
|
||||
{format(
|
||||
new Date(secretApprovalRequestDetails.createdAt),
|
||||
new Date(reviewer ? reviewer.createdAt : new Date()),
|
||||
"MM/dd/yyyy h:mm:ss aa"
|
||||
)}
|
||||
.
|
||||
|
||||
@@ -219,22 +219,43 @@ const Page = () => {
|
||||
ProjectPermissionSub.Commits
|
||||
);
|
||||
|
||||
const defaultFilterState = {
|
||||
tags: {},
|
||||
searchFilter: (routerQueryParams.search as string) || "",
|
||||
// these should always be on by default for the UI, they will be disabled for the query below based off permissions
|
||||
include: {
|
||||
[RowType.Folder]: false,
|
||||
[RowType.Import]: false,
|
||||
[RowType.DynamicSecret]: false,
|
||||
[RowType.Secret]: false,
|
||||
[RowType.SecretRotation]: false
|
||||
}
|
||||
};
|
||||
const getFilterStateFromQueryParams = useCallback(() => {
|
||||
const filterByArray = routerQueryParams.filterBy
|
||||
? (routerQueryParams.filterBy as string).split(",").filter(Boolean)
|
||||
: [];
|
||||
|
||||
const includeFilters = {
|
||||
[RowType.Folder]: filterByArray.includes("folder") || false,
|
||||
[RowType.Import]: filterByArray.includes("import") || false,
|
||||
[RowType.DynamicSecret]: filterByArray.includes("dynamic") || false,
|
||||
[RowType.Secret]: filterByArray.includes("secret") || false,
|
||||
[RowType.SecretRotation]: filterByArray.includes("rotation") || false
|
||||
};
|
||||
|
||||
const tags = routerQueryParams.tags
|
||||
? routerQueryParams.tags.split(",").reduce(
|
||||
(acc, tag) => {
|
||||
const trimmedTag = tag.trim();
|
||||
if (trimmedTag) {
|
||||
acc[trimmedTag] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>
|
||||
)
|
||||
: {};
|
||||
|
||||
return {
|
||||
tags,
|
||||
searchFilter: (routerQueryParams.search as string) || "",
|
||||
include: includeFilters
|
||||
};
|
||||
}, [routerQueryParams.search, routerQueryParams.tags, routerQueryParams.filterBy]);
|
||||
|
||||
const defaultFilterState = getFilterStateFromQueryParams();
|
||||
|
||||
const [filter, setFilter] = useState<Filter>(defaultFilterState);
|
||||
const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(filter.searchFilter);
|
||||
const [filterHistory, setFilterHistory] = useState<Map<string, Filter>>(new Map());
|
||||
|
||||
const createSecretPopUp = usePopUpState(PopUpNames.CreateSecretForm);
|
||||
const { togglePopUp } = usePopUpAction();
|
||||
@@ -475,34 +496,96 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const handleTagToggle = useCallback(
|
||||
(tagSlug: string) =>
|
||||
(tagSlug: string) => {
|
||||
setFilter((state) => {
|
||||
const isTagPresent = Boolean(state.tags?.[tagSlug]);
|
||||
const newTagFilter = { ...state.tags };
|
||||
if (isTagPresent) delete newTagFilter[tagSlug];
|
||||
else newTagFilter[tagSlug] = true;
|
||||
|
||||
// Update URL to match filter state
|
||||
const tagsList = Object.keys(newTagFilter).filter((tag) => newTagFilter[tag]);
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
tags: tagsList.length > 0 ? tagsList.join(",") : ""
|
||||
})
|
||||
});
|
||||
|
||||
return { ...state, tags: newTagFilter };
|
||||
}),
|
||||
[]
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleToggleRowType = useCallback(
|
||||
(rowType: RowType) =>
|
||||
(rowType: RowType) => {
|
||||
setFilter((state) => {
|
||||
const newInclude = {
|
||||
...state.include,
|
||||
[rowType]: !state.include[rowType]
|
||||
};
|
||||
|
||||
// Update URL to match filter state
|
||||
const filterByList: string[] = [];
|
||||
if (newInclude[RowType.Folder]) filterByList.push("folder");
|
||||
if (newInclude[RowType.Import]) filterByList.push("import");
|
||||
if (newInclude[RowType.DynamicSecret]) filterByList.push("dynamic");
|
||||
if (newInclude[RowType.Secret]) filterByList.push("secret");
|
||||
if (newInclude[RowType.SecretRotation]) filterByList.push("rotation");
|
||||
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
filterBy: filterByList.length > 0 ? filterByList.join(",") : ""
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
include: {
|
||||
...state.include,
|
||||
[rowType]: !state.include[rowType]
|
||||
}
|
||||
include: newInclude
|
||||
};
|
||||
}),
|
||||
[]
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilter({
|
||||
searchFilter: "",
|
||||
tags: {},
|
||||
include: {
|
||||
[RowType.Folder]: false,
|
||||
[RowType.Import]: false,
|
||||
[RowType.DynamicSecret]: false,
|
||||
[RowType.Secret]: false,
|
||||
[RowType.SecretRotation]: false
|
||||
}
|
||||
});
|
||||
setDebouncedSearchFilter("");
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
search: "",
|
||||
tags: "",
|
||||
filterBy: ""
|
||||
})
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })),
|
||||
[]
|
||||
(searchFilter: string) => {
|
||||
setFilter((state) => ({ ...state, searchFilter }));
|
||||
|
||||
// Update URL to match filter state
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
search: searchFilter || ""
|
||||
})
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleToggleVisibility = useCallback(() => setIsVisible((state) => !state), []);
|
||||
@@ -527,38 +610,10 @@ const Page = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// restore filters for path if set
|
||||
const restore = filterHistory.get(secretPath);
|
||||
setFilter(restore ?? defaultFilterState);
|
||||
setDebouncedSearchFilter(restore?.searchFilter ?? "");
|
||||
}, [secretPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routerQueryParams.search && !routerQueryParams.tags) return;
|
||||
|
||||
const queryTags = routerQueryParams.tags
|
||||
? (routerQueryParams.tags as string).split(",").filter((tag) => Boolean(tag.trim()))
|
||||
: [];
|
||||
const updatedTags: Record<string, boolean> = {};
|
||||
queryTags.forEach((tag) => {
|
||||
updatedTags[tag] = true;
|
||||
});
|
||||
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
...defaultFilterState,
|
||||
searchFilter: (routerQueryParams.search as string) ?? "",
|
||||
tags: updatedTags
|
||||
}));
|
||||
setDebouncedSearchFilter(routerQueryParams.search as string);
|
||||
// this is a temp workaround until we fully transition state to query params,
|
||||
navigate({
|
||||
search: (state) => {
|
||||
const { search, tags: qTags, ...query } = state;
|
||||
return query;
|
||||
}
|
||||
});
|
||||
}, [routerQueryParams.search, routerQueryParams.tags]);
|
||||
const filterState = getFilterStateFromQueryParams();
|
||||
setFilter(filterState);
|
||||
setDebouncedSearchFilter(filterState.searchFilter);
|
||||
}, [getFilterStateFromQueryParams]);
|
||||
|
||||
const selectedSecrets = useSelectedSecrets();
|
||||
const selectedSecretActions = useSelectedSecretActions();
|
||||
@@ -597,13 +652,6 @@ const Page = () => {
|
||||
}
|
||||
|
||||
const handleResetFilter = () => {
|
||||
// store for breadcrumb nav to restore previously used filters
|
||||
setFilterHistory((prev) => {
|
||||
const curr = new Map(prev);
|
||||
curr.set(secretPath, filter);
|
||||
return curr;
|
||||
});
|
||||
|
||||
setFilter(defaultFilterState);
|
||||
setDebouncedSearchFilter("");
|
||||
};
|
||||
@@ -838,19 +886,7 @@ const Page = () => {
|
||||
isPITEnabled={isPITEnabled}
|
||||
hasPathPolicies={hasPathPolicies}
|
||||
onRequestAccess={(params) => handlePopUpOpen("requestAccess", params)}
|
||||
onClearFilters={() =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
tags: {},
|
||||
include: {
|
||||
secret: false,
|
||||
import: false,
|
||||
dynamic: false,
|
||||
rotation: false,
|
||||
folder: false
|
||||
}
|
||||
}))
|
||||
}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
<div
|
||||
ref={tableRef}
|
||||
|
||||
+15
@@ -197,6 +197,21 @@ export const CreateSecretForm = ({
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
label="Value"
|
||||
tooltipText={
|
||||
<div>
|
||||
You can add references to other secrets using the format{" "}
|
||||
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">
|
||||
${"{"}secret_name{"}"}
|
||||
</code>
|
||||
<br />
|
||||
<br />
|
||||
You can go to the referenced secret by holding the{" "}
|
||||
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">Cmd</code> (Mac) or{" "}
|
||||
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">Ctrl</code>{" "}
|
||||
(Windows/Linux) key and clicking on the secret name.
|
||||
</div>
|
||||
}
|
||||
tooltipClassName="max-w-md"
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
>
|
||||
|
||||
+48
-7
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { faEye } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faArrowRotateRight,
|
||||
faBan,
|
||||
faDesktop,
|
||||
faEyeSlash,
|
||||
faServer,
|
||||
@@ -68,10 +69,14 @@ export const SecretVersionItem = ({
|
||||
const getLinkToModifyHistoryEntity = (
|
||||
actorId: string,
|
||||
actorType: string,
|
||||
membershipId: string | null = ""
|
||||
membershipId: string | null = "",
|
||||
groupId: string | null = "",
|
||||
actorName: string | null = ""
|
||||
) => {
|
||||
switch (actorType) {
|
||||
case ActorType.USER:
|
||||
if (groupId)
|
||||
return `/projects/secret-management/${currentProject.id}/groups/${groupId}?username=${actorName}`;
|
||||
return `/projects/secret-management/${currentProject.id}/members/${membershipId}`;
|
||||
case ActorType.IDENTITY:
|
||||
return `/projects/secret-management/${currentProject.id}/identities/${actorId}`;
|
||||
@@ -83,10 +88,26 @@ export const SecretVersionItem = ({
|
||||
const onModifyHistoryClick = (
|
||||
actorId: string | undefined | null,
|
||||
actorType: string | undefined | null,
|
||||
membershipId: string | undefined | null
|
||||
membershipId: string | undefined | null,
|
||||
groupId: string | undefined | null,
|
||||
actorName: string | undefined | null
|
||||
) => {
|
||||
if (!membershipId) {
|
||||
createNotification({
|
||||
type: "info",
|
||||
text: `This ${actorType === ActorType.USER ? "user" : "identity"} is no longer a member of this project.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (actorType && actorId && actorType !== ActorType.PLATFORM) {
|
||||
const redirectLink = getLinkToModifyHistoryEntity(actorId, actorType, membershipId);
|
||||
const redirectLink = getLinkToModifyHistoryEntity(
|
||||
actorId,
|
||||
actorType,
|
||||
membershipId,
|
||||
groupId,
|
||||
actorName
|
||||
);
|
||||
if (redirectLink) {
|
||||
navigate({ to: redirectLink });
|
||||
}
|
||||
@@ -157,15 +178,35 @@ export const SecretVersionItem = ({
|
||||
<div className="flex flex-row">
|
||||
<div className="flex w-fit flex-row text-sm">
|
||||
Modified by:
|
||||
<Tooltip content={getModifiedByName(actor.actorType, actor.name)}>
|
||||
<Tooltip
|
||||
className="z-[100] max-w-sm"
|
||||
content={
|
||||
getModifiedByName(actor.actorType, actor.name) +
|
||||
(!actor.membershipId && actor.actorId ? " (Removed from project)" : "")
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
onClick={() =>
|
||||
onModifyHistoryClick(actor.actorId, actor.actorType, actor.membershipId)
|
||||
onClick={
|
||||
actor.membershipId
|
||||
? () =>
|
||||
onModifyHistoryClick(
|
||||
actor.actorId,
|
||||
actor.actorType,
|
||||
actor.membershipId,
|
||||
actor.groupId,
|
||||
actor.name
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
className="cursor-pointer"
|
||||
className={actor.membershipId ? "cursor-pointer" : undefined}
|
||||
>
|
||||
<FontAwesomeIcon icon={getModifiedByIcon(actor.actorType)} className="ml-2" />
|
||||
{!actor.membershipId &&
|
||||
actor.actorType &&
|
||||
[ActorType.USER, ActorType.IDENTITY].includes(
|
||||
actor.actorType as ActorType
|
||||
) && <FontAwesomeIcon className="ml-1 text-mineshaft-400" icon={faBan} />}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ const SecretDashboardPageQueryParamsSchema = z.object({
|
||||
secretPath: z.string().catch("/"),
|
||||
search: z.string().catch(""),
|
||||
tags: z.string().catch(""),
|
||||
filterBy: z.string().catch(""),
|
||||
connectionId: z.string().optional(),
|
||||
connectionName: z.string().optional()
|
||||
});
|
||||
@@ -20,7 +21,7 @@ export const Route = createFileRoute(
|
||||
component: SecretDashboardPage,
|
||||
validateSearch: zodValidator(SecretDashboardPageQueryParamsSchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ secretPath: "/", search: "", tags: "" })]
|
||||
middlewares: [stripSearchParams({ secretPath: "/", search: "", tags: "", filterBy: "" })]
|
||||
},
|
||||
beforeLoad: ({ context, params, search }) => {
|
||||
const secretPathSegments = search.secretPath.split("/").filter(Boolean);
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ export const WorkflowIntegrationTab = () => {
|
||||
<Td>Provider</Td>
|
||||
<Td>Access Request Notifications Destination</Td>
|
||||
<Td>Secret Request Notifications Destination</Td>
|
||||
<Td />
|
||||
<Td>Secret Sync Error Notifications Destination</Td>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
|
||||
+3
-1
@@ -99,7 +99,9 @@ export const MicrosoftTeamsConfigRow = ({
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
|
||||
<Td>
|
||||
<Badge variant="danger">Disabled</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
|
||||
+19
-1
@@ -96,7 +96,25 @@ export const SlackConfigRow = ({ handlePopUpOpen, isSlackConfigLoading, slackCon
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
|
||||
<Td>
|
||||
{slackConfig.isSecretSyncErrorNotificationEnabled &&
|
||||
!isLoadingConfig &&
|
||||
slackConfig.secretSyncErrorChannels.length > 0 ? (
|
||||
<p>
|
||||
{slackConfig.secretSyncErrorChannels
|
||||
.split(", ")
|
||||
.map((channel) => slackChannelIdToName[channel])
|
||||
.join(", ")}
|
||||
</p>
|
||||
) : isLoadingConfig ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<Badge variant="neutral">
|
||||
<BanIcon />
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
|
||||
+144
-114
@@ -37,11 +37,76 @@ const formSchema = z.object({
|
||||
isSecretRequestNotificationEnabled: z.boolean(),
|
||||
secretRequestChannels: z.string().array(),
|
||||
isAccessRequestNotificationEnabled: z.boolean(),
|
||||
accessRequestChannels: z.string().array()
|
||||
accessRequestChannels: z.string().array(),
|
||||
isSecretSyncErrorNotificationEnabled: z.boolean(),
|
||||
secretSyncErrorChannels: z.string().array()
|
||||
});
|
||||
|
||||
type TSlackConfigForm = z.infer<typeof formSchema>;
|
||||
|
||||
type TChannelSelectorProps = {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
error?: { message?: string };
|
||||
slackChannelIdToName: Record<string, string>;
|
||||
sortedSlackChannels?: { id: string; name: string }[];
|
||||
keyPrefix: string;
|
||||
};
|
||||
|
||||
const ChannelSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
slackChannelIdToName,
|
||||
sortedSlackChannels,
|
||||
keyPrefix
|
||||
}: TChannelSelectorProps) => (
|
||||
<FormControl label="Slack channels" isError={Boolean(error)} errorText={error?.message}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={value
|
||||
?.filter(Boolean)
|
||||
.map((entry) => slackChannelIdToName[entry])
|
||||
.join(", ")}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
style={{
|
||||
width: "var(--radix-dropdown-menu-trigger-width)",
|
||||
maxHeight: "350px",
|
||||
overflowY: "auto"
|
||||
}}
|
||||
side="bottom"
|
||||
align="start"
|
||||
>
|
||||
{sortedSlackChannels?.map((slackChannel) => {
|
||||
const isChecked = value?.includes(slackChannel.id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
onChange(
|
||||
isChecked
|
||||
? value?.filter((el: string) => el !== slackChannel.id)
|
||||
: [...(value || []), slackChannel.id]
|
||||
);
|
||||
}}
|
||||
key={`${keyPrefix}-slack-channel-${slackChannel.id}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{slackChannel.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -71,7 +136,9 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
|
||||
isAccessRequestNotificationEnabled: false,
|
||||
accessRequestChannels: [],
|
||||
isSecretRequestNotificationEnabled: false,
|
||||
secretRequestChannels: []
|
||||
secretRequestChannels: [],
|
||||
isSecretSyncErrorNotificationEnabled: false,
|
||||
secretSyncErrorChannels: []
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,7 +153,8 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
|
||||
integration: WorkflowIntegrationPlatform.SLACK,
|
||||
integrationId: data.slackIntegrationId,
|
||||
accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "),
|
||||
secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ")
|
||||
secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", "),
|
||||
secretSyncErrorChannels: data.secretSyncErrorChannels.filter(Boolean).join(", ")
|
||||
});
|
||||
|
||||
createNotification({
|
||||
@@ -100,6 +168,7 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
|
||||
const secretRequestNotifState = watch("isSecretRequestNotificationEnabled");
|
||||
const selectedSlackIntegrationId = watch("slackIntegrationId");
|
||||
const accessRequestNotifState = watch("isAccessRequestNotificationEnabled");
|
||||
const secretSyncErrorNotifState = watch("isSecretSyncErrorNotificationEnabled");
|
||||
|
||||
const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId);
|
||||
const slackChannelIdToName = Object.fromEntries(
|
||||
@@ -110,7 +179,7 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (slackConfig) {
|
||||
if (slackConfig && slackConfig.integration === WorkflowIntegrationPlatform.SLACK) {
|
||||
setValue("slackIntegrationId", slackConfig.integrationId);
|
||||
setValue(
|
||||
"isSecretRequestNotificationEnabled",
|
||||
@@ -120,22 +189,30 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
|
||||
"isAccessRequestNotificationEnabled",
|
||||
slackConfig.isAccessRequestNotificationEnabled
|
||||
);
|
||||
setValue(
|
||||
"isSecretSyncErrorNotificationEnabled",
|
||||
slackConfig.isSecretSyncErrorNotificationEnabled
|
||||
);
|
||||
|
||||
if (slackConfig.integration === WorkflowIntegrationPlatform.SLACK) {
|
||||
if (slackChannels) {
|
||||
setValue(
|
||||
"secretRequestChannels",
|
||||
slackConfig.secretRequestChannels
|
||||
.split(", ")
|
||||
.filter((channel) => channel in slackChannelIdToName)
|
||||
);
|
||||
setValue(
|
||||
"accessRequestChannels",
|
||||
slackConfig.accessRequestChannels
|
||||
.split(", ")
|
||||
.filter((channel) => channel in slackChannelIdToName)
|
||||
);
|
||||
}
|
||||
if (slackChannels) {
|
||||
setValue(
|
||||
"secretRequestChannels",
|
||||
(slackConfig.secretRequestChannels || "")
|
||||
.split(", ")
|
||||
.filter((channel) => channel in slackChannelIdToName)
|
||||
);
|
||||
setValue(
|
||||
"accessRequestChannels",
|
||||
(slackConfig.accessRequestChannels || "")
|
||||
.split(", ")
|
||||
.filter((channel) => channel in slackChannelIdToName)
|
||||
);
|
||||
setValue(
|
||||
"secretSyncErrorChannels",
|
||||
(slackConfig.secretSyncErrorChannels || "")
|
||||
.split(", ")
|
||||
.filter((channel) => channel in slackChannelIdToName)
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [slackConfig, slackChannels]);
|
||||
@@ -208,54 +285,14 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
|
||||
control={control}
|
||||
name="secretRequestChannels"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Slack channels"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={value
|
||||
?.filter(Boolean)
|
||||
.map((entry) => slackChannelIdToName[entry])
|
||||
.join(", ")}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
style={{
|
||||
width: "var(--radix-dropdown-menu-trigger-width)",
|
||||
maxHeight: "350px",
|
||||
overflowY: "auto"
|
||||
}}
|
||||
side="bottom"
|
||||
align="start"
|
||||
>
|
||||
{sortedSlackChannels?.map((slackChannel) => {
|
||||
const isChecked = value?.includes(slackChannel.id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
onChange(
|
||||
isChecked
|
||||
? value?.filter((el: string) => el !== slackChannel.id)
|
||||
: [...(value || []), slackChannel.id]
|
||||
);
|
||||
}}
|
||||
key={`secret-requests-slack-channel-${slackChannel.id}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{slackChannel.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<ChannelSelector
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
slackChannelIdToName={slackChannelIdToName}
|
||||
sortedSlackChannels={sortedSlackChannels}
|
||||
keyPrefix="secret-requests"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -281,54 +318,47 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
|
||||
control={control}
|
||||
name="accessRequestChannels"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Slack channels"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={value
|
||||
?.filter(Boolean)
|
||||
.map((entry) => slackChannelIdToName[entry])
|
||||
.join(", ")}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
style={{
|
||||
width: "var(--radix-dropdown-menu-trigger-width)",
|
||||
maxHeight: "350px",
|
||||
overflowY: "auto"
|
||||
}}
|
||||
side="bottom"
|
||||
align="start"
|
||||
>
|
||||
{sortedSlackChannels?.map((slackChannel) => {
|
||||
const isChecked = value?.includes(slackChannel.id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
onChange(
|
||||
isChecked
|
||||
? value?.filter((el: string) => el !== slackChannel.id)
|
||||
: [...(value || []), slackChannel.id]
|
||||
);
|
||||
}}
|
||||
key={`access-requests-slack-channel-${slackChannel.id}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{slackChannel.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ChannelSelector
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
slackChannelIdToName={slackChannelIdToName}
|
||||
sortedSlackChannels={sortedSlackChannels}
|
||||
keyPrefix="access-requests"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="isSecretSyncErrorNotificationEnabled"
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-2">
|
||||
<Switch
|
||||
id="secret-sync-error-notification"
|
||||
onCheckedChange={(value) => field.onChange(value)}
|
||||
isChecked={field.value}
|
||||
>
|
||||
<p className="w-full">Secret Sync Errors</p>
|
||||
</Switch>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{secretSyncErrorNotifState && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretSyncErrorChannels"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<ChannelSelector
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
slackChannelIdToName={slackChannelIdToName}
|
||||
sortedSlackChannels={sortedSlackChannels}
|
||||
keyPrefix="secret-sync-errors"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState } from "react";
|
||||
import { faPlus, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
Input,
|
||||
Pagination,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useDeletePkiAlertV2, useGetPkiAlertsV2 } from "@app/hooks/api/pkiAlertsV2";
|
||||
|
||||
import { CreatePkiAlertV2Modal } from "./components/CreatePkiAlertV2Modal";
|
||||
import { PkiAlertV2Row } from "./components/PkiAlertV2Row";
|
||||
import { ViewPkiAlertV2Modal } from "./components/ViewPkiAlertV2Modal";
|
||||
|
||||
interface Props {
|
||||
hideContainer?: boolean;
|
||||
}
|
||||
|
||||
export const PkiAlertsV2Page = ({ hideContainer = false }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(25);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
|
||||
const [alertModal, setAlertModal] = useState<{ isOpen: boolean; alertId?: string }>({
|
||||
isOpen: false
|
||||
});
|
||||
const [viewModal, setViewModal] = useState<{ isOpen: boolean; alertId?: string }>({
|
||||
isOpen: false
|
||||
});
|
||||
const [deleteModal, setDeleteModal] = useState<{
|
||||
isOpen: boolean;
|
||||
alertId?: string;
|
||||
name?: string;
|
||||
}>({
|
||||
isOpen: false
|
||||
});
|
||||
|
||||
const { data: alertsData, isLoading } = useGetPkiAlertsV2({
|
||||
projectId: currentProject?.id || "",
|
||||
search: debouncedSearch || undefined,
|
||||
limit: perPage,
|
||||
offset: (page - 1) * perPage
|
||||
});
|
||||
|
||||
const { mutateAsync: deletePkiAlert } = useDeletePkiAlertV2();
|
||||
|
||||
const handleDeleteAlert = async () => {
|
||||
if (!deleteModal.alertId) return;
|
||||
|
||||
try {
|
||||
await deletePkiAlert({ alertId: deleteModal.alertId });
|
||||
setDeleteModal({ isOpen: false });
|
||||
createNotification({
|
||||
text: "PKI alert deleted successfully",
|
||||
type: "success"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to delete PKI alert",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil((alertsData?.total || 0) / perPage);
|
||||
|
||||
const renderTableContent = () => {
|
||||
if (isLoading) {
|
||||
return Array.from({ length: 5 }, (_, index) => (
|
||||
<Tr key={`skeleton-${index}`}>
|
||||
<Td>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</Td>
|
||||
<Td className="text-right">
|
||||
<Skeleton className="ml-auto h-4 w-24" />
|
||||
</Td>
|
||||
</Tr>
|
||||
));
|
||||
}
|
||||
|
||||
if (alertsData?.alerts?.length) {
|
||||
return alertsData.alerts.map((alert) => (
|
||||
<PkiAlertV2Row
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
onView={() => setViewModal({ isOpen: true, alertId: alert.id })}
|
||||
onEdit={() => setAlertModal({ isOpen: true, alertId: alert.id })}
|
||||
onDelete={() =>
|
||||
setDeleteModal({
|
||||
isOpen: true,
|
||||
alertId: alert.id,
|
||||
name: alert.name
|
||||
})
|
||||
}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td colSpan={5} className="py-8 text-center text-gray-400">
|
||||
{search ? "No alerts found matching your search." : "No PKI alerts configured yet."}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={hideContainer ? "" : "container mx-auto p-6"}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex w-full items-center space-x-4">
|
||||
<div className="relative mr-2 w-full">
|
||||
<FontAwesomeIcon
|
||||
icon={faSearch}
|
||||
className="absolute top-1/2 left-3 -translate-y-1/2 transform text-gray-400"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search alerts..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => setAlertModal({ isOpen: true })}
|
||||
>
|
||||
Create Certificate Alert
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Event Type</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Alert Before</Th>
|
||||
<Th className="text-right">Actions</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>{renderTableContent()}</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page}
|
||||
onChangePage={setPage}
|
||||
perPage={perPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreatePkiAlertV2Modal
|
||||
isOpen={alertModal.isOpen}
|
||||
onOpenChange={(isOpen) => setAlertModal({ isOpen, alertId: undefined })}
|
||||
alertId={alertModal.alertId}
|
||||
/>
|
||||
|
||||
<ViewPkiAlertV2Modal
|
||||
isOpen={viewModal.isOpen}
|
||||
onOpenChange={(isOpen) => setViewModal({ isOpen, alertId: undefined })}
|
||||
alertId={viewModal.alertId}
|
||||
/>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Delete PKI Alert "${deleteModal.name}"`}
|
||||
onChange={(isOpen) => setDeleteModal({ isOpen, alertId: undefined, name: undefined })}
|
||||
onDeleteApproved={handleDeleteAlert}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,592 @@
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
import { CertificateDisplayName } from "@app/components/utilities/certificateDisplayUtils";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
GenericFieldLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Select,
|
||||
SelectItem,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Td,
|
||||
TextArea,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v3";
|
||||
import { useProject } from "@app/context";
|
||||
import {
|
||||
PkiAlertEventTypeV2,
|
||||
PkiFilterFieldV2,
|
||||
PkiFilterOperatorV2,
|
||||
TCreatePkiAlertV2,
|
||||
TPkiAlertChannelV2,
|
||||
TPkiFilterRuleV2,
|
||||
useGetPkiAlertV2CurrentMatchingCertificates
|
||||
} from "@app/hooks/api/pkiAlertsV2";
|
||||
|
||||
export const CreatePkiAlertV2FormSteps = () => {
|
||||
const { control, watch, setValue } = useFormContext<TCreatePkiAlertV2>();
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const [certificatesPage, setCertificatesPage] = useState(1);
|
||||
const certificatesPerPage = 10;
|
||||
|
||||
const watchedFilters = watch("filters");
|
||||
const watchedChannels = watch("channels");
|
||||
const watchedEventType = watch("eventType");
|
||||
const watchedAlertBefore = watch("alertBefore");
|
||||
|
||||
const { data: currentCertificatesData, isLoading: isLoadingCurrentCertificates } =
|
||||
useGetPkiAlertV2CurrentMatchingCertificates(
|
||||
{
|
||||
projectId: currentProject?.id || "",
|
||||
filters: watchedFilters || [],
|
||||
alertBefore: watchedAlertBefore || "30d",
|
||||
limit: certificatesPerPage,
|
||||
offset: (certificatesPage - 1) * certificatesPerPage
|
||||
},
|
||||
{
|
||||
enabled: !!currentProject?.id && watchedEventType === PkiAlertEventTypeV2.EXPIRATION,
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
);
|
||||
|
||||
const addFilter = () => {
|
||||
const currentFilters = watchedFilters || [];
|
||||
setValue("filters", [
|
||||
...currentFilters,
|
||||
{
|
||||
field: PkiFilterFieldV2.COMMON_NAME,
|
||||
operator: PkiFilterOperatorV2.CONTAINS,
|
||||
value: ""
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
const removeFilter = (index: number) => {
|
||||
const currentFilters = watchedFilters || [];
|
||||
setValue(
|
||||
"filters",
|
||||
currentFilters.filter((_, i) => i !== index)
|
||||
);
|
||||
};
|
||||
|
||||
const updateFilter = (index: number, updatedFilter: Partial<TPkiFilterRuleV2>) => {
|
||||
const currentFilters = [...(watchedFilters || [])];
|
||||
currentFilters[index] = { ...currentFilters[index], ...updatedFilter };
|
||||
setValue("filters", currentFilters);
|
||||
};
|
||||
|
||||
const updateChannel = (
|
||||
index: number,
|
||||
updatedChannel: Partial<Omit<TPkiAlertChannelV2, "id" | "createdAt" | "updatedAt">>
|
||||
) => {
|
||||
const currentChannels = [...(watchedChannels || [])];
|
||||
currentChannels[index] = { ...currentChannels[index], ...updatedChannel };
|
||||
setValue("channels", currentChannels);
|
||||
};
|
||||
|
||||
const getFieldOperators = (field: PkiFilterFieldV2) => {
|
||||
if (field === PkiFilterFieldV2.INCLUDE_CAS) {
|
||||
return [PkiFilterOperatorV2.EQUALS];
|
||||
}
|
||||
return Object.values(PkiFilterOperatorV2);
|
||||
};
|
||||
|
||||
const isValueBoolean = (field: PkiFilterFieldV2) => {
|
||||
return field === PkiFilterFieldV2.INCLUDE_CAS;
|
||||
};
|
||||
|
||||
const canOperatorTakeArray = (operator: PkiFilterOperatorV2) => {
|
||||
return operator === PkiFilterOperatorV2.MATCHES;
|
||||
};
|
||||
|
||||
const formatEventType = (eventType: PkiAlertEventTypeV2) => {
|
||||
switch (eventType) {
|
||||
case PkiAlertEventTypeV2.EXPIRATION:
|
||||
return "Certificate Expiration";
|
||||
case PkiAlertEventTypeV2.RENEWAL:
|
||||
return "Certificate Renewal";
|
||||
case PkiAlertEventTypeV2.ISSUANCE:
|
||||
return "Certificate Issuance";
|
||||
case PkiAlertEventTypeV2.REVOCATION:
|
||||
return "Certificate Revocation";
|
||||
default:
|
||||
return eventType;
|
||||
}
|
||||
};
|
||||
|
||||
const formatAlertBefore = (alertBefore?: string) => {
|
||||
if (!alertBefore) return "-";
|
||||
|
||||
const match = alertBefore.match(/^(\d+)([dwmy])$/);
|
||||
if (!match) return alertBefore;
|
||||
|
||||
const [, value, unit] = match;
|
||||
const unitMap = {
|
||||
d: "days",
|
||||
w: "weeks",
|
||||
m: "months",
|
||||
y: "years"
|
||||
};
|
||||
|
||||
return `${value} ${unitMap[unit as keyof typeof unitMap] || unit}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tab.Panel>
|
||||
<div className="space-y-6">
|
||||
<p className="mb-4 text-sm text-bunker-300">
|
||||
Choose the event that will trigger this alert notification.
|
||||
</p>
|
||||
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="eventType"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Alert Type" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => field.onChange(value)}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value={PkiAlertEventTypeV2.EXPIRATION}>
|
||||
Certificate Expiration
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
|
||||
<Tab.Panel>
|
||||
<div className="space-y-6">
|
||||
<p className="mb-4 text-sm text-bunker-300">
|
||||
Configure the name, description, and timing for your alert.
|
||||
</p>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Alert Name" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="e.g., tls-expiry-alert" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Description (Optional)"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea {...field} placeholder="Alert description..." rows={2} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedEventType === PkiAlertEventTypeV2.EXPIRATION && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="alertBefore"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Alert Before"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Format: number + unit (d=days, w=weeks, m=months, y=years). Example: 30d"
|
||||
>
|
||||
<Input {...field} placeholder="30d" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
|
||||
<Tab.Panel>
|
||||
<div className="space-y-6">
|
||||
<p className="mb-4 text-sm text-bunker-300">
|
||||
Add filter rules to specify which certificates should trigger this alert. Leave empty to
|
||||
monitor all certificates.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel label="Certificate Filter Rules" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={addFilter}
|
||||
>
|
||||
Add Filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{watchedFilters?.map((filter, index) => (
|
||||
<div
|
||||
key={`filter-${index}`}
|
||||
className="space-y-2 rounded-md border border-mineshaft-600 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-mineshaft-100">
|
||||
Filter Rule #{index + 1}
|
||||
</h4>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="Remove filter"
|
||||
onClick={() => removeFilter(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<FormControl label="Field">
|
||||
<Select
|
||||
value={filter.field}
|
||||
onValueChange={(value) =>
|
||||
updateFilter(index, {
|
||||
field: value as PkiFilterFieldV2,
|
||||
operator:
|
||||
value === PkiFilterFieldV2.INCLUDE_CAS
|
||||
? PkiFilterOperatorV2.EQUALS
|
||||
: filter.operator,
|
||||
value: isValueBoolean(value as PkiFilterFieldV2) ? false : ""
|
||||
})
|
||||
}
|
||||
className="w-full min-w-[200px]"
|
||||
>
|
||||
<SelectItem value={PkiFilterFieldV2.COMMON_NAME}>Common Name</SelectItem>
|
||||
<SelectItem value={PkiFilterFieldV2.PROFILE_NAME}>Profile Name</SelectItem>
|
||||
<SelectItem value={PkiFilterFieldV2.SAN}>
|
||||
Subject Alternative Names
|
||||
</SelectItem>
|
||||
<SelectItem value={PkiFilterFieldV2.INCLUDE_CAS}>
|
||||
Include Certificate Authorities
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<FormControl label="Operator">
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(value) =>
|
||||
updateFilter(index, { operator: value as PkiFilterOperatorV2 })
|
||||
}
|
||||
className="w-full min-w-[140px]"
|
||||
>
|
||||
{getFieldOperators(filter.field).map((operator) => (
|
||||
<SelectItem key={operator} value={operator}>
|
||||
{operator
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<FormControl label="Value">
|
||||
{isValueBoolean(filter.field) ? (
|
||||
<Select
|
||||
value={String(filter.value)}
|
||||
onValueChange={(value) =>
|
||||
updateFilter(index, { value: value === "true" })
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value="true">Yes</SelectItem>
|
||||
<SelectItem value="false">No</SelectItem>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={
|
||||
Array.isArray(filter.value)
|
||||
? filter.value.join(", ")
|
||||
: String(filter.value || "")
|
||||
}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
updateFilter(index, { value });
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const { value } = e.target;
|
||||
if (canOperatorTakeArray(filter.operator) && value.includes(",")) {
|
||||
const finalValue = value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
updateFilter(index, { value: finalValue });
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
canOperatorTakeArray(filter.operator)
|
||||
? "example.com, test.com"
|
||||
: "example.com"
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!watchedFilters || watchedFilters.length === 0) && (
|
||||
<div className="py-8 text-center text-bunker-400">
|
||||
No filter rules configured. This alert will monitor all certificates.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
|
||||
<Tab.Panel>
|
||||
<div className="space-y-6">
|
||||
<p className="mb-4 text-sm text-bunker-300">
|
||||
Preview all certificates that match your filter criteria. This shows all non-expired
|
||||
certificates that would be monitored by this alert.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/2">SAN / CN</Th>
|
||||
<Th className="w-1/4">Not Before</Th>
|
||||
<Th className="w-1/4">Not After</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{(() => {
|
||||
if (watchedEventType !== PkiAlertEventTypeV2.EXPIRATION) {
|
||||
return (
|
||||
<Tr>
|
||||
<Td colSpan={3} className="py-8 text-center text-gray-400">
|
||||
Preview is only available for Certificate Expiration alerts
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingCurrentCertificates) {
|
||||
return Array.from({ length: 5 }, (_, index) => (
|
||||
<Tr key={`skeleton-row-${index}`}>
|
||||
<Td>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</Td>
|
||||
</Tr>
|
||||
));
|
||||
}
|
||||
|
||||
if (currentCertificatesData?.certificates?.length) {
|
||||
return currentCertificatesData.certificates.map((cert) => (
|
||||
<Tr key={cert.id} className="group h-10">
|
||||
<Td className="max-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CertificateDisplayName
|
||||
cert={{
|
||||
altNames: cert.san?.join(", ") || null,
|
||||
commonName: cert.commonName
|
||||
}}
|
||||
maxLength={48}
|
||||
fallback="—"
|
||||
/>
|
||||
{(cert.enrollmentType === "ca" ||
|
||||
cert.enrollmentType === "internal-ca") && (
|
||||
<Badge variant="info" className="shrink-0 text-xs">
|
||||
CA
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{cert.notBefore
|
||||
? new Date(cert.notBefore).toLocaleDateString("en-CA")
|
||||
: "-"}
|
||||
</Td>
|
||||
<Td>
|
||||
{cert.notAfter
|
||||
? new Date(cert.notAfter).toLocaleDateString("en-CA")
|
||||
: "-"}
|
||||
</Td>
|
||||
</Tr>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td colSpan={3} className="py-8 text-center text-gray-400">
|
||||
No certificates currently match this alert's criteria
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})()}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{(currentCertificatesData?.total || 0) > 0 && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
count={currentCertificatesData?.total || 0}
|
||||
page={certificatesPage}
|
||||
onChangePage={setCertificatesPage}
|
||||
perPage={certificatesPerPage}
|
||||
onChangePerPage={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
|
||||
<Tab.Panel>
|
||||
<div className="space-y-6">
|
||||
<p className="mb-4 text-sm text-bunker-300">
|
||||
Set up email notifications to receive alerts when events occur.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormControl label="Email Recipients">
|
||||
<TextArea
|
||||
value={
|
||||
Array.isArray((watchedChannels?.[0]?.config as any)?.recipients)
|
||||
? (watchedChannels[0].config as any).recipients.join(", ")
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const emailList = e.target.value
|
||||
.split(",")
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email.length > 0);
|
||||
updateChannel(0, { config: { recipients: emailList }, enabled: true });
|
||||
}}
|
||||
placeholder="[email protected], [email protected]"
|
||||
className="w-full"
|
||||
rows={2}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
|
||||
<Tab.Panel>
|
||||
<div className="mb-4 flex flex-col gap-6">
|
||||
<p className="mb-4 text-sm text-bunker-300">
|
||||
Please review the settings below before creating your alert.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="w-full border-b border-mineshaft-600">
|
||||
<span className="text-sm text-mineshaft-300">Basic Information</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||
<GenericFieldLabel label="Name">{watch("name") || "Not specified"}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Event Type">
|
||||
{formatEventType(watchedEventType)}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Status">
|
||||
<div className="mt-1">
|
||||
<Badge variant={watch("enabled") ? "success" : "danger"}>
|
||||
{watch("enabled") ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
</GenericFieldLabel>
|
||||
{watchedEventType === PkiAlertEventTypeV2.EXPIRATION && (
|
||||
<GenericFieldLabel label="Alert Before">
|
||||
{formatAlertBefore(watch("alertBefore"))}
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
{watch("description") && (
|
||||
<GenericFieldLabel label="Description">{watch("description")}</GenericFieldLabel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="w-full border-b border-mineshaft-600">
|
||||
<span className="text-sm text-mineshaft-300">Filter Rules</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||
{watchedFilters && watchedFilters.length > 0 ? (
|
||||
watchedFilters.map((filter, index) => (
|
||||
<GenericFieldLabel key={`review-filter-${index}`} label={`Rule ${index + 1}`}>
|
||||
<span className="font-mono text-xs">
|
||||
{filter.field
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())}{" "}
|
||||
{filter.operator
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())}{" "}
|
||||
"{String(filter.value)}"
|
||||
</span>
|
||||
</GenericFieldLabel>
|
||||
))
|
||||
) : (
|
||||
<span className="text-bunker-400">
|
||||
No filter rules - will monitor all certificates
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="w-full border-b border-mineshaft-600">
|
||||
<span className="text-sm text-mineshaft-300">Notifications</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3">
|
||||
<GenericFieldLabel label="Email Recipients">
|
||||
{Array.isArray((watchedChannels?.[0]?.config as any)?.recipients)
|
||||
? (watchedChannels[0].config as any).recipients.join(", ")
|
||||
: (watchedChannels?.[0]?.config as any)?.recipients || "No recipients"}
|
||||
</GenericFieldLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
import React, { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import {
|
||||
createPkiAlertV2Schema,
|
||||
PkiAlertChannelTypeV2,
|
||||
PkiAlertEventTypeV2,
|
||||
TCreatePkiAlertV2,
|
||||
TPkiAlertChannelConfigEmail,
|
||||
TPkiAlertV2,
|
||||
TUpdatePkiAlertV2,
|
||||
updatePkiAlertV2Schema,
|
||||
useCreatePkiAlertV2,
|
||||
useGetPkiAlertV2ById,
|
||||
useUpdatePkiAlertV2
|
||||
} from "@app/hooks/api/pkiAlertsV2";
|
||||
|
||||
import { CreatePkiAlertV2FormSteps } from "./CreatePkiAlertV2FormSteps";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
alertToEdit?: TPkiAlertV2;
|
||||
alertId?: string;
|
||||
}
|
||||
|
||||
type TFormData = TCreatePkiAlertV2;
|
||||
|
||||
const FORM_TABS: { name: string; key: string; fields: (keyof TFormData)[] }[] = [
|
||||
{ name: "Alert Type", key: "alertType", fields: ["eventType"] },
|
||||
{
|
||||
name: "Details",
|
||||
key: "basicInfo",
|
||||
fields: ["name", "description", "alertBefore"]
|
||||
},
|
||||
{ name: "Filters", key: "filterRules", fields: ["filters"] },
|
||||
{ name: "Preview", key: "preview", fields: [] },
|
||||
{ name: "Channels", key: "channels", fields: ["channels"] },
|
||||
{ name: "Review", key: "review", fields: [] }
|
||||
];
|
||||
|
||||
export const CreatePkiAlertV2Modal = ({ isOpen, onOpenChange, alertToEdit, alertId }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
|
||||
const { data: fetchedAlert } = useGetPkiAlertV2ById(
|
||||
{ alertId: alertId || "" },
|
||||
{ enabled: !!alertId && isOpen && !alertToEdit }
|
||||
);
|
||||
|
||||
const editingAlert = alertToEdit || fetchedAlert;
|
||||
const isEditing = !!(editingAlert || alertId);
|
||||
|
||||
const formMethods = useForm<TFormData>({
|
||||
resolver: zodResolver(isEditing ? updatePkiAlertV2Schema : createPkiAlertV2Schema),
|
||||
defaultValues: {
|
||||
projectId: currentProject?.id || "",
|
||||
name: "",
|
||||
description: "",
|
||||
eventType: PkiAlertEventTypeV2.EXPIRATION,
|
||||
alertBefore: "30d",
|
||||
filters: [],
|
||||
enabled: true,
|
||||
channels: [
|
||||
{
|
||||
channelType: PkiAlertChannelTypeV2.EMAIL,
|
||||
config: { recipients: [] },
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
},
|
||||
reValidateMode: "onBlur"
|
||||
});
|
||||
|
||||
const { handleSubmit, trigger, reset } = formMethods;
|
||||
|
||||
const { mutateAsync: createAlert } = useCreatePkiAlertV2();
|
||||
const { mutateAsync: updateAlert } = useUpdatePkiAlertV2();
|
||||
|
||||
const handleModalClose = () => {
|
||||
reset();
|
||||
setSelectedTabIndex(0);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editingAlert && isEditing) {
|
||||
reset({
|
||||
projectId: currentProject?.id || "",
|
||||
name: editingAlert.name,
|
||||
description: editingAlert.description || "",
|
||||
eventType: editingAlert.eventType,
|
||||
alertBefore: editingAlert.alertBefore || "30d",
|
||||
filters: editingAlert.filters || [],
|
||||
enabled: editingAlert.enabled,
|
||||
channels: editingAlert.channels?.map(({ id, createdAt, updatedAt, ...channel }) => {
|
||||
if (channel.channelType === PkiAlertChannelTypeV2.EMAIL) {
|
||||
const emailConfig = channel.config as TPkiAlertChannelConfigEmail;
|
||||
return {
|
||||
...channel,
|
||||
config: {
|
||||
recipients: Array.isArray(emailConfig.recipients) ? emailConfig.recipients : []
|
||||
}
|
||||
};
|
||||
}
|
||||
return channel;
|
||||
}) || [
|
||||
{
|
||||
channelType: PkiAlertChannelTypeV2.EMAIL,
|
||||
config: { recipients: [] },
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
});
|
||||
} else if (!isEditing) {
|
||||
reset({
|
||||
projectId: currentProject?.id || "",
|
||||
name: "",
|
||||
description: "",
|
||||
eventType: PkiAlertEventTypeV2.EXPIRATION,
|
||||
alertBefore: "30d",
|
||||
filters: [],
|
||||
enabled: true,
|
||||
channels: [
|
||||
{
|
||||
channelType: PkiAlertChannelTypeV2.EMAIL,
|
||||
config: { recipients: [] },
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}, [editingAlert, isEditing, currentProject?.id, reset]);
|
||||
|
||||
const onSubmit = async (data: TFormData) => {
|
||||
if (!currentProject?.id) return;
|
||||
|
||||
const processedData = {
|
||||
...data,
|
||||
channels: data.channels?.map((channel) => {
|
||||
if (channel.channelType === PkiAlertChannelTypeV2.EMAIL) {
|
||||
const emailConfig = channel.config as TPkiAlertChannelConfigEmail;
|
||||
return {
|
||||
...channel,
|
||||
config: {
|
||||
recipients: Array.isArray(emailConfig.recipients) ? emailConfig.recipients : []
|
||||
}
|
||||
};
|
||||
}
|
||||
return channel;
|
||||
})
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEditing && (alertId || editingAlert?.id)) {
|
||||
await updateAlert({
|
||||
alertId: alertId || editingAlert!.id,
|
||||
...processedData
|
||||
} as TUpdatePkiAlertV2);
|
||||
} else {
|
||||
await createAlert({
|
||||
...processedData,
|
||||
projectId: currentProject.id
|
||||
});
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: `PKI alert ${isEditing ? "updated" : "created"} successfully`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handleModalClose();
|
||||
} catch {
|
||||
createNotification({
|
||||
text: `Failed to ${isEditing ? "update" : "create"} PKI alert`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (selectedTabIndex === 0) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
setSelectedTabIndex((prev) => prev - 1);
|
||||
};
|
||||
|
||||
const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
|
||||
|
||||
const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isFinalStep) {
|
||||
await handleSubmit(onSubmit)();
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await isStepValid(selectedTabIndex);
|
||||
if (!isValid) return;
|
||||
|
||||
setSelectedTabIndex((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const isTabEnabled = async (index: number) => {
|
||||
const validationPromises = [];
|
||||
for (let i = index - 1; i >= 0; i -= 1) {
|
||||
validationPromises.push(isStepValid(i));
|
||||
}
|
||||
const results = await Promise.all(validationPromises);
|
||||
return results.every(Boolean);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={handleModalClose}>
|
||||
<ModalContent
|
||||
title={`${isEditing ? "Update" : "Create"} Certificate Alert`}
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<form className={twMerge(isFinalStep && "max-h-[70vh] overflow-y-auto")}>
|
||||
<FormProvider {...formMethods}>
|
||||
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
|
||||
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
|
||||
{FORM_TABS.map((tab, index) => (
|
||||
<Tab
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
const isEnabled = await isTabEnabled(index);
|
||||
setSelectedTabIndex((prev) => (isEnabled ? index : prev));
|
||||
}}
|
||||
className={({ selected }) =>
|
||||
`-mb-[0.14rem] whitespace-nowrap ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-hidden disabled:opacity-60 ${
|
||||
selected
|
||||
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
|
||||
: "text-bunker-300"
|
||||
}`
|
||||
}
|
||||
key={tab.key}
|
||||
>
|
||||
{index + 1}. {tab.name}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<CreatePkiAlertV2FormSteps />
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</FormProvider>
|
||||
|
||||
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
|
||||
<Button onClick={handleNext} colorSchema="secondary">
|
||||
{isFinalStep ? `${isEditing ? "Update" : "Create"} Alert` : "Next"}
|
||||
</Button>
|
||||
<Button onClick={handlePrev} colorSchema="secondary">
|
||||
{selectedTabIndex === 0 ? "Cancel" : "Back"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
faCircleInfo,
|
||||
faEllipsisH,
|
||||
faEye,
|
||||
faPencil,
|
||||
faPlay,
|
||||
faStop,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v3";
|
||||
import { PkiAlertEventTypeV2, TPkiAlertV2, useUpdatePkiAlertV2 } from "@app/hooks/api/pkiAlertsV2";
|
||||
|
||||
interface Props {
|
||||
alert: TPkiAlertV2;
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const PkiAlertV2Row = ({ alert, onView, onEdit, onDelete }: Props) => {
|
||||
const { mutateAsync: updateAlert } = useUpdatePkiAlertV2();
|
||||
|
||||
const formatEventType = (eventType: PkiAlertEventTypeV2) => {
|
||||
switch (eventType) {
|
||||
case PkiAlertEventTypeV2.EXPIRATION:
|
||||
return "Certificate Expiration";
|
||||
case PkiAlertEventTypeV2.RENEWAL:
|
||||
return "Certificate Renewal";
|
||||
case PkiAlertEventTypeV2.ISSUANCE:
|
||||
return "Certificate Issuance";
|
||||
case PkiAlertEventTypeV2.REVOCATION:
|
||||
return "Certificate Revocation";
|
||||
default:
|
||||
return eventType;
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAlert = async () => {
|
||||
try {
|
||||
await updateAlert({
|
||||
alertId: alert.id,
|
||||
enabled: !alert.enabled
|
||||
});
|
||||
createNotification({
|
||||
text: `Alert ${!alert.enabled ? "enabled" : "disabled"} successfully`,
|
||||
type: "success"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to update alert status",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatAlertBefore = (alertBefore?: string) => {
|
||||
if (!alertBefore) return "-";
|
||||
|
||||
const match = alertBefore.match(/^(\\d+)([dwmy])$/);
|
||||
if (!match) return alertBefore;
|
||||
|
||||
const [, value, unit] = match;
|
||||
const unitMap = {
|
||||
d: "days",
|
||||
w: "weeks",
|
||||
m: "months",
|
||||
y: "years"
|
||||
};
|
||||
|
||||
return `${value} ${unitMap[unit as keyof typeof unitMap] || unit}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-gray-200">{alert.name}</div>
|
||||
{alert.description && (
|
||||
<Tooltip content={alert.description}>
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-gray-300">{formatEventType(alert.eventType)}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={alert.enabled ? "success" : "neutral"}>
|
||||
{alert.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td className="text-gray-300">{formatAlertBefore(alert.alertBefore)}</Td>
|
||||
<Td className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton size="sm" variant="plain" ariaLabel="Alert actions">
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onView}>
|
||||
<FontAwesomeIcon icon={faEye} className="mr-2 h-4 w-4" />
|
||||
View details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<FontAwesomeIcon icon={faPencil} className="mr-2 h-4 w-4" />
|
||||
Edit alert
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleToggleAlert}>
|
||||
<FontAwesomeIcon icon={alert.enabled ? faStop : faPlay} className="mr-2 h-4 w-4" />
|
||||
{alert.enabled ? "Disable" : "Enable"} alert
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-red-600">
|
||||
<FontAwesomeIcon icon={faTrash} className="mr-2 h-4 w-4" />
|
||||
Delete alert
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
Skeleton,
|
||||
Switch,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
PkiAlertChannelTypeV2,
|
||||
PkiAlertEventTypeV2,
|
||||
PkiFilterFieldV2,
|
||||
PkiFilterOperatorV2,
|
||||
TPkiAlertChannelV2,
|
||||
TPkiFilterRuleV2,
|
||||
TUpdatePkiAlertV2,
|
||||
updatePkiAlertV2Schema,
|
||||
useGetPkiAlertV2ById,
|
||||
useUpdatePkiAlertV2
|
||||
} from "@app/hooks/api/pkiAlertsV2";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
alertId?: string;
|
||||
}
|
||||
|
||||
type TFormData = Omit<TUpdatePkiAlertV2, "alertId">;
|
||||
|
||||
export const UpdatePkiAlertV2Modal = ({ isOpen, onOpenChange, alertId }: Props) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { control, handleSubmit, reset, watch, setValue } = useForm<TFormData>({
|
||||
resolver: zodResolver(updatePkiAlertV2Schema)
|
||||
});
|
||||
|
||||
const { data: alert, isLoading } = useGetPkiAlertV2ById(
|
||||
{ alertId: alertId || "" },
|
||||
{ enabled: !!alertId && isOpen }
|
||||
);
|
||||
|
||||
const { mutateAsync: updateAlert } = useUpdatePkiAlertV2();
|
||||
|
||||
const watchedFilters = watch("filters");
|
||||
const watchedChannels = watch("channels");
|
||||
|
||||
useEffect(() => {
|
||||
if (alert) {
|
||||
reset({
|
||||
name: alert.name,
|
||||
description: alert.description || "",
|
||||
eventType: alert.eventType,
|
||||
alertBefore: alert.alertBefore || "",
|
||||
filters: alert.filters,
|
||||
enabled: alert.enabled,
|
||||
channels: alert.channels.map(({ id, createdAt, updatedAt, ...channel }) => channel)
|
||||
});
|
||||
}
|
||||
}, [alert, reset]);
|
||||
|
||||
const addFilter = () => {
|
||||
const currentFilters = watchedFilters || [];
|
||||
setValue("filters", [
|
||||
...currentFilters,
|
||||
{
|
||||
field: PkiFilterFieldV2.COMMON_NAME,
|
||||
operator: PkiFilterOperatorV2.CONTAINS,
|
||||
value: ""
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
const removeFilter = (index: number) => {
|
||||
const currentFilters = watchedFilters || [];
|
||||
setValue(
|
||||
"filters",
|
||||
currentFilters.filter((_, i) => i !== index)
|
||||
);
|
||||
};
|
||||
|
||||
const updateFilter = (index: number, updatedFilter: Partial<TPkiFilterRuleV2>) => {
|
||||
const currentFilters = [...(watchedFilters || [])];
|
||||
currentFilters[index] = { ...currentFilters[index], ...updatedFilter };
|
||||
setValue("filters", currentFilters);
|
||||
};
|
||||
|
||||
const addChannel = () => {
|
||||
const currentChannels = watchedChannels || [];
|
||||
setValue("channels", [
|
||||
...currentChannels,
|
||||
{
|
||||
channelType: PkiAlertChannelTypeV2.EMAIL,
|
||||
config: { recipients: [] },
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
const removeChannel = (index: number) => {
|
||||
const currentChannels = watchedChannels || [];
|
||||
if (currentChannels.length > 1) {
|
||||
setValue(
|
||||
"channels",
|
||||
currentChannels.filter((_, i) => i !== index)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const updateChannel = (
|
||||
index: number,
|
||||
updatedChannel: Partial<Omit<TPkiAlertChannelV2, "id" | "createdAt" | "updatedAt">>
|
||||
) => {
|
||||
const currentChannels = [...(watchedChannels || [])];
|
||||
currentChannels[index] = { ...currentChannels[index], ...updatedChannel };
|
||||
setValue("channels", currentChannels);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: TFormData) => {
|
||||
if (!alertId) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updateAlert({
|
||||
alertId,
|
||||
...data
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "PKI alert updated successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to update PKI alert",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldOperators = (field: PkiFilterFieldV2) => {
|
||||
if (field === PkiFilterFieldV2.INCLUDE_CAS) {
|
||||
return [PkiFilterOperatorV2.EQUALS];
|
||||
}
|
||||
return Object.values(PkiFilterOperatorV2);
|
||||
};
|
||||
|
||||
const isValueBoolean = (field: PkiFilterFieldV2) => {
|
||||
return field === PkiFilterFieldV2.INCLUDE_CAS;
|
||||
};
|
||||
|
||||
const canOperatorTakeArray = (operator: PkiFilterOperatorV2) => {
|
||||
return operator === PkiFilterOperatorV2.MATCHES;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent title="Update Certificate Alert" className="max-w-4xl">
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent title="Update Certificate Alert" className="max-w-4xl">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Alert Name" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="certificate-expiration-alert" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="eventType"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Event Type" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Select value={field.value} onValueChange={(value) => field.onChange(value)}>
|
||||
<SelectItem value={PkiAlertEventTypeV2.EXPIRATION}>
|
||||
Certificate Expiration
|
||||
</SelectItem>
|
||||
<SelectItem value={PkiAlertEventTypeV2.RENEWAL}>Certificate Renewal</SelectItem>
|
||||
<SelectItem value={PkiAlertEventTypeV2.ISSUANCE}>
|
||||
Certificate Issuance
|
||||
</SelectItem>
|
||||
<SelectItem value={PkiAlertEventTypeV2.REVOCATION}>
|
||||
Certificate Revocation
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Description (Optional)"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea {...field} placeholder="Alert description..." rows={2} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="alertBefore"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Alert Before (for expiration)"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Format: number + unit (d=days, w=weeks, m=months, y=years). Example: 30d"
|
||||
>
|
||||
<Input {...field} placeholder="30d" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Status">
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<Switch
|
||||
id="alert-enabled-update"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{field.value ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel label="Certificate Filter Rules" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={addFilter}
|
||||
>
|
||||
Add Filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{watchedFilters?.map((filter, index) => (
|
||||
<div
|
||||
key={`filter-${filter.field}-${filter.operator}-${String(filter.value)}`}
|
||||
className="space-y-3 rounded-lg border border-gray-600 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-200">Filter Rule #{index + 1}</h4>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="Remove filter"
|
||||
onClick={() => removeFilter(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<FormControl label="Field">
|
||||
<Select
|
||||
value={filter.field}
|
||||
onValueChange={(value) =>
|
||||
updateFilter(index, {
|
||||
field: value as PkiFilterFieldV2,
|
||||
operator:
|
||||
value === PkiFilterFieldV2.INCLUDE_CAS
|
||||
? PkiFilterOperatorV2.EQUALS
|
||||
: filter.operator,
|
||||
value: isValueBoolean(value as PkiFilterFieldV2) ? false : ""
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectItem value={PkiFilterFieldV2.COMMON_NAME}>Common Name</SelectItem>
|
||||
<SelectItem value={PkiFilterFieldV2.PROFILE_NAME}>Profile Name</SelectItem>
|
||||
<SelectItem value={PkiFilterFieldV2.SAN}>
|
||||
Subject Alternative Names
|
||||
</SelectItem>
|
||||
<SelectItem value={PkiFilterFieldV2.INCLUDE_CAS}>
|
||||
Include Certificate Authorities
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Operator">
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(value) =>
|
||||
updateFilter(index, { operator: value as PkiFilterOperatorV2 })
|
||||
}
|
||||
>
|
||||
{getFieldOperators(filter.field).map((operator) => (
|
||||
<SelectItem key={operator} value={operator}>
|
||||
{operator.replace("_", " ").toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Value">
|
||||
{isValueBoolean(filter.field) ? (
|
||||
<Select
|
||||
value={String(filter.value)}
|
||||
onValueChange={(value) => updateFilter(index, { value: value === "true" })}
|
||||
>
|
||||
<SelectItem value="true">Yes</SelectItem>
|
||||
<SelectItem value="false">No</SelectItem>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={
|
||||
Array.isArray(filter.value)
|
||||
? filter.value.join(", ")
|
||||
: String(filter.value)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
const finalValue =
|
||||
canOperatorTakeArray(filter.operator) && value.includes(",")
|
||||
? value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)
|
||||
: value;
|
||||
updateFilter(index, { value: finalValue });
|
||||
}}
|
||||
placeholder={
|
||||
canOperatorTakeArray(filter.operator)
|
||||
? "example.com, test.com"
|
||||
: "example.com"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel label="Notification Channels" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={addChannel}
|
||||
>
|
||||
Add Channel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{watchedChannels?.map((channel, index) => (
|
||||
<div
|
||||
key={`channel-${channel.channelType}-${JSON.stringify(channel.config)}`}
|
||||
className="space-y-3 rounded-lg border border-gray-600 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-200">Channel #{index + 1}</h4>
|
||||
{watchedChannels.length > 1 && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="Remove channel"
|
||||
onClick={() => removeChannel(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<FormControl label="Channel Type">
|
||||
<Select
|
||||
value={channel.channelType}
|
||||
onValueChange={(value) =>
|
||||
updateChannel(index, {
|
||||
channelType: value as PkiAlertChannelTypeV2,
|
||||
config: { recipients: [] }
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectItem value={PkiAlertChannelTypeV2.EMAIL}>Email</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Status">
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<Switch
|
||||
id={`channel-enabled-update-${index}`}
|
||||
isChecked={channel.enabled}
|
||||
onCheckedChange={(enabled) => updateChannel(index, { enabled })}
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{channel.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormControl label="Email Recipients">
|
||||
<Input
|
||||
value={(channel.config as any)?.recipients?.join(", ") || ""}
|
||||
onChange={(e) => {
|
||||
const recipients = e.target.value
|
||||
.split(",")
|
||||
.map((email) => email.trim())
|
||||
.filter(Boolean);
|
||||
updateChannel(index, { config: { recipients } });
|
||||
}}
|
||||
placeholder="[email protected], [email protected]"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button type="button" variant="plain" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
|
||||
Update Alert
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { CertificateDisplayName } from "@app/components/utilities/certificateDisplayUtils";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
Pagination,
|
||||
Skeleton,
|
||||
Tab,
|
||||
Table,
|
||||
TableContainer,
|
||||
TabList,
|
||||
TabPanel,
|
||||
Tabs,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v3";
|
||||
import {
|
||||
PkiAlertEventTypeV2,
|
||||
useGetPkiAlertV2ById,
|
||||
useGetPkiAlertV2MatchingCertificates
|
||||
} from "@app/hooks/api/pkiAlertsV2";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
alertId?: string;
|
||||
}
|
||||
|
||||
export const ViewPkiAlertV2Modal = ({ isOpen, onOpenChange, alertId }: Props) => {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [certificatesPage, setCertificatesPage] = useState(1);
|
||||
const certificatesPerPage = 10;
|
||||
|
||||
const { data: alert, isLoading: isLoadingAlert } = useGetPkiAlertV2ById(
|
||||
{ alertId: alertId || "" },
|
||||
{ enabled: !!alertId && isOpen }
|
||||
);
|
||||
|
||||
const { data: certificatesData, isLoading: isLoadingCertificates } =
|
||||
useGetPkiAlertV2MatchingCertificates(
|
||||
{
|
||||
alertId: alertId || "",
|
||||
limit: certificatesPerPage,
|
||||
offset: (certificatesPage - 1) * certificatesPerPage
|
||||
},
|
||||
{ enabled: !!alertId && isOpen }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && alertId) {
|
||||
setActiveTab("overview");
|
||||
setCertificatesPage(1);
|
||||
}
|
||||
}, [isOpen, alertId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setActiveTab("overview");
|
||||
setCertificatesPage(1);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const formatEventType = (eventType: PkiAlertEventTypeV2) => {
|
||||
switch (eventType) {
|
||||
case PkiAlertEventTypeV2.EXPIRATION:
|
||||
return "Certificate Expiration";
|
||||
case PkiAlertEventTypeV2.RENEWAL:
|
||||
return "Certificate Renewal";
|
||||
case PkiAlertEventTypeV2.ISSUANCE:
|
||||
return "Certificate Issuance";
|
||||
case PkiAlertEventTypeV2.REVOCATION:
|
||||
return "Certificate Revocation";
|
||||
default:
|
||||
return eventType;
|
||||
}
|
||||
};
|
||||
|
||||
const formatAlertBefore = (alertBefore?: string) => {
|
||||
if (!alertBefore) return "Not set";
|
||||
|
||||
const match = alertBefore.match(/^(\\d+)([dwmy])$/);
|
||||
if (!match) return alertBefore;
|
||||
|
||||
const [, value, unit] = match;
|
||||
const unitMap = {
|
||||
d: "days",
|
||||
w: "weeks",
|
||||
m: "months",
|
||||
y: "years"
|
||||
};
|
||||
|
||||
return `${value} ${unitMap[unit as keyof typeof unitMap] || unit}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingAlert) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent title="Certificate Alert Details" className="max-w-6xl">
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
if (!alert) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent title="Certificate Alert Details" className="max-w-6xl">
|
||||
<div className="py-8 text-center text-gray-400">Alert not found</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent title="Certificate Alert Details" className="max-w-6xl">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-200">{alert.name}</h2>
|
||||
<Badge variant={alert.enabled ? "success" : "neutral"}>
|
||||
{alert.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{alert.description && <p className="text-gray-300">{alert.description}</p>}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabList>
|
||||
<Tab value="overview">Overview</Tab>
|
||||
<Tab value="certificates">Matching Certificates</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel value="overview">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-200">Basic Information</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<div className="mb-1 block text-sm font-medium text-gray-400">Event Type</div>
|
||||
<span className="text-gray-300">{formatEventType(alert.eventType)}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 block text-sm font-medium text-gray-400">
|
||||
Alert Before
|
||||
</div>
|
||||
<span className="text-gray-300">{formatAlertBefore(alert.alertBefore)}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 block text-sm font-medium text-gray-400">Created</div>
|
||||
<span className="text-gray-300">{formatDate(alert.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-medium text-gray-200">Filter Rules</h3>
|
||||
{(alert.filters || []).length === 0 ? (
|
||||
<p className="text-gray-400">No filter rules configured</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(alert.filters || []).map((filter) => {
|
||||
const formatFilterText = () => {
|
||||
const field = filter.field.replace(/_/g, " ");
|
||||
const operator = filter.operator.replace(/_/g, " ");
|
||||
const value = Array.isArray(filter.value)
|
||||
? filter.value.join(", ")
|
||||
: String(filter.value);
|
||||
|
||||
return `${field} ${operator} ${value}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`filter-${filter.field}-${filter.operator}-${String(filter.value)}`}
|
||||
>
|
||||
<div className="text-sm text-gray-200 capitalize">
|
||||
{formatFilterText()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-medium text-gray-200">Notification Recipients</h3>
|
||||
{(alert.channels || []).some(
|
||||
(channel) =>
|
||||
channel.channelType === "email" && (channel.config as any)?.recipients
|
||||
) ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(alert.channels || [])
|
||||
.filter(
|
||||
(channel) =>
|
||||
channel.channelType === "email" &&
|
||||
(channel.config as any)?.recipients
|
||||
)
|
||||
.flatMap((channel) => (channel.config as any).recipients)
|
||||
.map((email: string) => (
|
||||
<Badge key={`email-${email}`} variant="neutral" className="text-sm">
|
||||
{email}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400">No notification channels configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="certificates">
|
||||
<div className="space-y-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/2">SAN / CN</Th>
|
||||
<Th className="w-1/6">Status</Th>
|
||||
<Th className="w-1/6">Not Before</Th>
|
||||
<Th className="w-1/6">Not After</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{(() => {
|
||||
if (isLoadingCertificates) {
|
||||
return Array.from({ length: 5 }, (_, index) => (
|
||||
<Tr key={`skeleton-row-${index}`}>
|
||||
<Td>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</Td>
|
||||
</Tr>
|
||||
));
|
||||
}
|
||||
|
||||
if (certificatesData?.certificates?.length) {
|
||||
return certificatesData.certificates.map((cert) => (
|
||||
<Tr key={cert.id} className="group h-10">
|
||||
<Td className="max-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CertificateDisplayName
|
||||
cert={{
|
||||
altNames: cert.san?.join(", ") || null,
|
||||
commonName: cert.commonName
|
||||
}}
|
||||
maxLength={48}
|
||||
fallback="—"
|
||||
/>
|
||||
{cert.enrollmentType === "ca" && (
|
||||
<Badge variant="info" className="shrink-0 text-xs">
|
||||
CA
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge
|
||||
variant={(() => {
|
||||
const now = new Date();
|
||||
const expirationDate = new Date(cert.notAfter);
|
||||
const isExpired = expirationDate < now;
|
||||
|
||||
if (isExpired) return "danger";
|
||||
if (cert.status === "active") return "success";
|
||||
return "neutral";
|
||||
})()}
|
||||
className="capitalize"
|
||||
>
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
const expirationDate = new Date(cert.notAfter);
|
||||
const isExpired = expirationDate < now;
|
||||
|
||||
if (isExpired) return "Expired";
|
||||
return cert.status;
|
||||
})()}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
{cert.notBefore
|
||||
? new Date(cert.notBefore).toLocaleDateString("en-CA")
|
||||
: "-"}
|
||||
</Td>
|
||||
<Td>
|
||||
{cert.notAfter
|
||||
? new Date(cert.notAfter).toLocaleDateString("en-CA")
|
||||
: "-"}
|
||||
</Td>
|
||||
</Tr>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td colSpan={4} className="py-8 text-center text-gray-400">
|
||||
No certificates will match this alert's criteria in the future
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})()}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{(certificatesData?.total || 0) > 0 && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
count={certificatesData?.total || 0}
|
||||
page={certificatesPage}
|
||||
onChangePage={setCertificatesPage}
|
||||
perPage={certificatesPerPage}
|
||||
onChangePerPage={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { CreatePkiAlertV2Modal } from "./CreatePkiAlertV2Modal";
|
||||
export { PkiAlertV2Row } from "./PkiAlertV2Row";
|
||||
export { UpdatePkiAlertV2Modal } from "./UpdatePkiAlertV2Modal";
|
||||
export { ViewPkiAlertV2Modal } from "./ViewPkiAlertV2Modal";
|
||||
@@ -0,0 +1 @@
|
||||
export { PkiAlertsV2Page } from "./PkiAlertsV2Page";
|
||||
Reference in New Issue
Block a user