Merge branch 'main' into feature/secret-reference-shortcut

This commit is contained in:
Victor Santos
2025-11-10 14:20:23 -03:00
241 changed files with 13145 additions and 897 deletions
@@ -16,12 +16,14 @@ const syntaxHighlight = (
onHoverPart?: (part: string) => void,
hoveredPart?: string,
isCmdOrCtrlPressed?: boolean,
onClickSegment?: (segment: string, allSegments: string[]) => void
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;
@@ -132,6 +134,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
isLoadingValue,
isErrorLoadingValue,
onClickSegment,
placeholder,
...props
},
ref
@@ -176,7 +179,12 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
<div className="relative overflow-hidden">
<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),
@@ -188,12 +196,14 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
},
hoveredPart,
isCmdOrCtrlPressed,
onClickSegment
onClickSegment,
placeholder
)}
</span>
</code>
</pre>
<textarea
placeholder={placeholder}
style={{ whiteSpace: "break-spaces" }}
aria-label="secret value"
ref={ref}
+3
View File
@@ -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 {
+1
View File
@@ -7,6 +7,7 @@ declare global {
POSTHOG_API_KEY?: string;
INTERCOM_ID?: string;
TELEMETRY_CAPTURING_ENABLED: string;
ACME_FEATURE_ENABLED?: boolean;
};
}
}
+3
View File
@@ -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;
};
}
);
+12 -1
View File
@@ -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 } = {
+11 -1
View File
@@ -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"
}
+2 -2
View File
@@ -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;
@@ -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;
@@ -86,7 +86,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 +119,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;
@@ -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
});
};
+192
View File
@@ -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;
@@ -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>
@@ -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>
@@ -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}?`}
@@ -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
@@ -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}
/>
))}
@@ -5,6 +5,7 @@ import {
faCopy,
faEdit,
faEllipsis,
faEye,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
@@ -36,10 +37,16 @@ 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);
@@ -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("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>
@@ -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>
);
};
@@ -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"
@@ -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"
)}
.
@@ -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>
@@ -99,7 +99,9 @@ export const MicrosoftTeamsConfigRow = ({
</Badge>
)}
</Td>
<Td>
<Badge variant="danger">Disabled</Badge>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
@@ -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">
@@ -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., prod-cert-expiring-soon" />
</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&apos;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())}{" "}
&quot;{String(filter.value)}&quot;
</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&apos;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";