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
@@ -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"
/>
)}
/>
)}