feature secret rotations v2

This commit is contained in:
Scott Wilson
2025-03-31 20:34:48 -07:00
parent da094383b8
commit 766c1242fd
275 changed files with 9692 additions and 783 deletions
@@ -0,0 +1,79 @@
import { useState } from "react";
import { SecretRotationV2Form } from "@app/components/secret-rotations-v2/forms";
import { SecretRotationV2ModalHeader } from "@app/components/secret-rotations-v2/SecretRotationV2ModalHeader";
import { SecretRotationV2Select } from "@app/components/secret-rotations-v2/SecretRotationV2Select";
import { Modal, ModalContent } from "@app/components/v2";
import { SecretRotation, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
type SharedProps = {
secretPath: string;
environment?: string;
environments?: WorkspaceEnv[];
};
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
} & SharedProps;
type ContentProps = {
onComplete: (secretRotation: TSecretRotationV2) => void;
selectedRotation: SecretRotation | null;
setSelectedRotation: (selectedRotation: SecretRotation | null) => void;
} & SharedProps;
const Content = ({ setSelectedRotation, selectedRotation, ...props }: ContentProps) => {
if (selectedRotation) {
return (
<SecretRotationV2Form
onCancel={() => setSelectedRotation(null)}
type={selectedRotation}
{...props}
/>
);
}
return <SecretRotationV2Select onSelect={setSelectedRotation} />;
};
export const CreateSecretRotationV2Modal = ({ onOpenChange, isOpen, ...props }: Props) => {
const [selectedRotation, setSelectedRotation] = useState<SecretRotation | null>(null);
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) setSelectedRotation(null);
onOpenChange(open);
}}
>
<ModalContent
title={
selectedRotation ? (
<SecretRotationV2ModalHeader isConfigured={false} type={selectedRotation} />
) : (
"Add Secret Rotation"
)
}
onPointerDownOutside={(e) => e.preventDefault()}
className={selectedRotation ? "max-w-2xl" : "max-w-3xl"}
subTitle={
selectedRotation ? undefined : "Select a provider to create a secret rotation for."
}
bodyClassName="overflow-visible"
>
<Content
onComplete={() => {
setSelectedRotation(null);
onOpenChange(false);
}}
selectedRotation={selectedRotation}
setSelectedRotation={setSelectedRotation}
{...props}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal, Switch } from "@app/components/v2";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { useDeleteSecretRotationV2 } from "@app/hooks/api/secretRotationsV2/mutations";
type Props = {
secretRotation?: TSecretRotationV2;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onComplete?: () => void;
};
export const DeleteSecretRotationV2Modal = ({
isOpen,
onOpenChange,
secretRotation,
onComplete
}: Props) => {
const deleteSecretRotation = useDeleteSecretRotationV2();
const [revokeGeneratedCredentials, setRevokeGeneratedCredentials] = useState(false);
const [deleteSecrets, setDeleteSecrets] = useState(false);
useEffect(() => {
if (!isOpen) {
setRevokeGeneratedCredentials(false);
setDeleteSecrets(false);
}
}, [isOpen]);
if (!secretRotation) return null;
const { id: rotationId, name, type, projectId, folder } = secretRotation;
const handleDeleteSecretRotation = async () => {
const rotationType = SECRET_ROTATION_MAP[type].name;
try {
await deleteSecretRotation.mutateAsync({
rotationId,
type,
revokeGeneratedCredentials,
deleteSecrets,
projectId,
secretPath: folder.path
});
createNotification({
text: `Successfully deleted ${rotationType} Rotation`,
type: "success"
});
if (onComplete) onComplete();
onOpenChange(false);
} catch {
createNotification({
text: `Failed to delete ${rotationType} Rotation`,
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure want to delete ${name}?`}
deleteKey={name}
onDeleteApproved={handleDeleteSecretRotation}
>
<Switch
containerClassName="mt-4"
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-red/50"
thumbClassName="bg-mineshaft-800"
isChecked={revokeGeneratedCredentials}
onCheckedChange={setRevokeGeneratedCredentials}
id="revoke-credentials"
>
Revoke Credentials
</Switch>
<p className="mt-1 font-inter text-sm text-mineshaft-400">
Generated credentials will {revokeGeneratedCredentials ? "" : "not"} be revoked on deletion
{revokeGeneratedCredentials ? "" : " and remain active"}.
</p>
<Switch
containerClassName="mt-4"
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-red/50"
thumbClassName="bg-mineshaft-800"
isChecked={deleteSecrets}
onCheckedChange={setDeleteSecrets}
id="delete-secrets"
>
Delete Secrets
</Switch>
<p className="mt-1 font-inter text-sm text-mineshaft-400">
Rotation secrets will {deleteSecrets ? "" : "not"} be removed from your project on deletion.
</p>
</DeleteActionModal>
);
};
@@ -0,0 +1,34 @@
import { SecretRotationV2ModalHeader } from "@app/components/secret-rotations-v2/SecretRotationV2ModalHeader";
import { Modal, ModalContent } from "@app/components/v2";
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { SecretRotationV2Form } from "./forms";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
secretRotation?: TSecretRotationV2;
};
export const EditSecretRotationV2Modal = ({ secretRotation, onOpenChange, ...props }: Props) => {
if (!secretRotation) return null;
return (
<Modal {...props} onOpenChange={onOpenChange}>
<ModalContent
title={<SecretRotationV2ModalHeader isConfigured type={secretRotation.type} />}
className="max-w-2xl"
bodyClassName="overflow-visible"
>
<SecretRotationV2Form
onComplete={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
secretRotation={secretRotation}
type={secretRotation.type}
secretPath={secretRotation.folder.path}
environment={secretRotation.environment.slug}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,88 @@
import { createNotification } from "@app/components/notifications";
import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { useRotateSecretRotationV2 } from "@app/hooks/api/secretRotationsV2/mutations";
type Props = {
secretRotation?: TSecretRotationV2;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
secretRotation: TSecretRotationV2;
onComplete: () => void;
};
const Content = ({ secretRotation, onComplete }: ContentProps) => {
const rotateSecrets = useRotateSecretRotationV2();
const { id: rotationId, type, projectId, folder } = secretRotation;
const rotationType = SECRET_ROTATION_MAP[type].name;
const handleRotateSecrets = async () => {
try {
await rotateSecrets.mutateAsync({
rotationId,
type,
projectId,
secretPath: folder.path
});
createNotification({
text: `Successfully rotated ${rotationType} secrets`,
type: "success"
});
onComplete();
} catch (err) {
console.error(err);
createNotification({
text: `Failed to rotate ${rotationType} secrets`,
type: "error"
});
}
};
return (
<div>
<p className="mb-8 text-sm text-mineshaft-200">
Are you sure you want to rotate the secrets for this {rotationType} Rotation?
</p>
<div className="mt-8 flex w-full items-center justify-between gap-2">
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
<Button
onClick={handleRotateSecrets}
isDisabled={rotateSecrets.isPending}
isLoading={rotateSecrets.isPending}
colorSchema="secondary"
>
Rotate Secrets
</Button>
</div>
</div>
);
};
export const RotateSecretRotationV2Modal = ({ isOpen, onOpenChange, secretRotation }: Props) => {
if (!secretRotation) return null;
const rotationType = SECRET_ROTATION_MAP[secretRotation.type].name;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Rotate Secrets"
subTitle={`Rotate the secrets for this ${rotationType}.`}
>
<Content secretRotation={secretRotation} onComplete={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,49 @@
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
type Props = {
type: SecretRotation;
isConfigured: boolean;
};
export const SecretRotationV2ModalHeader = ({ type, isConfigured }: Props) => {
const destinationDetails = SECRET_ROTATION_MAP[type];
return (
<div className="flex w-full items-start gap-2">
<img
alt={`${destinationDetails.name} logo`}
src={`/images/integrations/${destinationDetails.image}`}
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
/>
<div>
<div className="flex items-center text-mineshaft-300">
{destinationDetails.name} Rotation
<a
target="_blank"
href={`https://infisical.com/docs/platform/secret-rotations/${type}`}
className="mb-1 ml-1"
rel="noopener noreferrer"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mb-[0.03rem] mr-1 text-[12px]" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm leading-4 text-mineshaft-400">
{isConfigured
? `Edit ${destinationDetails.name} Rotation`
: `Rotate ${destinationDetails.name}`}
</p>
</div>
</div>
);
};
@@ -0,0 +1,151 @@
import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Spinner, Tooltip } from "@app/components/v2";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import { SecretRotation, useSecretRotationV2Options } from "@app/hooks/api/secretRotationsV2";
type Props = {
onSelect: (type: SecretRotation) => void;
};
export const SecretRotationV2Select = ({ onSelect }: Props) => {
const { isPending, data: secretRotationOptions } = useSecretRotationV2Options();
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
</div>
);
}
return (
<div className="grid grid-cols-3 gap-2">
{secretRotationOptions?.map(({ type }) => {
const { image, name } = SECRET_ROTATION_MAP[type];
let size: number;
switch (type) {
case SecretRotation.MsSqlCredentials:
size = 50;
break;
default:
size = 45;
}
return (
<button
type="button"
key={type}
onClick={() => onSelect(type)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<img
src={`/images/integrations/${image}`}
width={size}
className="mt-auto"
alt={`${name} logo`}
/>
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
{/* templates for kubecon, remove once implemented */}
{[
{
name: "MySQL Credentials",
image: "MySql.png"
},
{
name: "SendGrid API Key",
image: "SendGrid.png"
},
{
name: "AWS IAM User Credentials",
image: "Amazon Web Services.png"
}
].map(({ name, image }) => {
let size: number;
switch (name) {
case "MySQL Credentials":
size = 80;
break;
case "SendGrid API Key":
size = 50;
break;
default:
size = 45;
}
return (
<button
type="button"
key={name}
onClick={() =>
createNotification({
type: "info",
text: `${name} Rotation is under development. Please check back soon.`
})
}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<img
src={`/images/integrations/${image}`}
width={size}
className="mt-auto"
alt={`${name} logo`}
/>
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more services.</p>
<p>
{`If you don't see the third-party
service you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<div className="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4 hover:bg-mineshaft-900/50">
<FontAwesomeIcon className="mt-auto text-3xl" icon={faWrench} />
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
Coming Soon
</div>
</div>
</Tooltip>
</div>
);
};
@@ -0,0 +1,119 @@
import { faBan, faRotate, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistanceToNow } from "date-fns";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "@app/components/v2";
import { Badge, BadgeProps } from "@app/components/v2/Badge/Badge";
import { SecretRotationStatus, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
type Props = {
secretRotation: TSecretRotationV2;
className?: string;
};
export const SecretRotationV2StatusBadge = ({ secretRotation, className }: Props) => {
const { isAutoRotationEnabled, rotationStatus, nextRotationAt, lastRotationMessage } =
secretRotation;
if (rotationStatus === SecretRotationStatus.Failed) {
let errorMessage = lastRotationMessage;
if (lastRotationMessage) {
try {
errorMessage = JSON.stringify(JSON.parse(lastRotationMessage), null, 2);
} catch {
errorMessage = lastRotationMessage;
}
}
return (
<Tooltip
position="left"
className="max-w-sm select-text"
content={
<div className="flex flex-col gap-2 whitespace-normal py-1">
<div>
<div className="mb-2 flex self-start text-red">
<FontAwesomeIcon icon={faXmark} className="ml-1 pr-1.5 pt-0.5 text-sm" />
<div className="text-xs">Failure Reason</div>
</div>
<div className="break-words rounded bg-mineshaft-600 p-2 text-xs">{errorMessage}</div>
</div>
{nextRotationAt && (
<span className="text-xs text-mineshaft-300">
Next rotation attempt on {format(nextRotationAt, "MM/dd/yyyy")} at{" "}
{format(nextRotationAt, "h:mm aa")}.
</span>
)}
</div>
}
>
<div>
<Badge
variant="danger"
className={twMerge("flex h-5 w-min items-center gap-1.5 whitespace-nowrap", className)}
>
<FontAwesomeIcon icon={faXmark} />
Rotation Failed
</Badge>
</div>
</Tooltip>
);
}
if (!isAutoRotationEnabled) {
return (
<Badge
className={twMerge(
"flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300",
className
)}
>
<FontAwesomeIcon icon={faBan} />
Auto-Rotation Disabled
</Badge>
);
}
const daysToRotation =
(new Date(nextRotationAt).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24);
let variant: BadgeProps["variant"];
let label: string;
let tooltipContent: string;
if (daysToRotation >= 7) {
variant = "success";
label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`;
tooltipContent = `Rotates ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`;
} else if (daysToRotation < 0) {
variant = "primary";
label = "Rotating";
tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`;
} else if (daysToRotation < 1) {
variant = "primary";
label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`;
tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`;
} else {
variant = "primary";
label = `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`;
tooltipContent = `Rotates on ${format(nextRotationAt, "MM/dd/yyyy")} at ${format(nextRotationAt, "h:mm aa")}.`;
}
return (
<Tooltip className="max-w-lg" content={tooltipContent}>
<div>
<Badge
variant={variant}
className={twMerge(
"flex h-5 w-min items-center gap-1.5 whitespace-nowrap capitalize",
className
)}
>
<FontAwesomeIcon icon={faRotate} />
{label}
</Badge>
</div>
</Tooltip>
);
};
@@ -0,0 +1,96 @@
import { ReactNode } from "react";
import { faRotate } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { Modal, ModalContent, Spinner } from "@app/components/v2";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import {
SecretRotation,
TSecretRotationV2,
useViewSecretRotationV2GeneratedCredentials
} from "@app/hooks/api/secretRotationsV2";
import { ViewSqlRotationGeneratedCredentials } from "./shared";
type Props = {
secretRotation?: TSecretRotationV2;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
secretRotation: TSecretRotationV2;
};
const Content = ({ secretRotation }: ContentProps) => {
const { id: rotationId, type, nextRotationAt } = secretRotation;
const { data: generatedCredentialsResponse, isPending } =
useViewSecretRotationV2GeneratedCredentials({
rotationId,
type
});
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading generated credentials...</p>
</div>
);
}
let Component: ReactNode;
switch (generatedCredentialsResponse!.type) {
case SecretRotation.PostgresCredentials:
case SecretRotation.MsSqlCredentials:
Component = (
<ViewSqlRotationGeneratedCredentials
generatedCredentialsResponse={generatedCredentialsResponse!}
/>
);
break;
default:
throw new Error("Unhandled View Generated Credential Rotation Type");
}
return (
<div className="flex flex-col gap-y-4">
{Component}
{nextRotationAt && (
<div className="flex items-center gap-x-1.5 text-sm text-mineshaft-300">
<FontAwesomeIcon icon={faRotate} className="text-mineshaft-400" />
<span>
Next rotation occurs on: {format(nextRotationAt, "MM/dd/yyyy")} at{" "}
{format(nextRotationAt, "h:mm aa")}
</span>
</div>
)}
</div>
);
};
export const ViewSecretRotationV2GeneratedCredentialsModal = ({
isOpen,
onOpenChange,
secretRotation
}: Props) => {
if (!secretRotation) return null;
const rotationType = SECRET_ROTATION_MAP[secretRotation.type].name;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
onOpenAutoFocus={(event) => {
event.preventDefault();
}}
title="Generated Credentials"
subTitle={`View the current and retired ${rotationType}.`}
>
<Content secretRotation={secretRotation} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export * from "./ViewSecretRotationV2GeneratedCredentials";
@@ -0,0 +1,55 @@
import { useReducer } from "react";
import { faCheck, faCopy, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { GenericFieldLabel, IconButton, Tooltip } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
type Props = {
children?: string;
label: string;
isSensitive?: boolean;
};
export const CredentialDisplay = ({ children, label, isSensitive }: Props) => {
const [showCredential, toggleShowCredential] = useReducer((prev) => !prev, !isSensitive);
const [, isCopyingCredential, setCopyCredential] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
return (
<GenericFieldLabel label={label}>
{children ? (
<div className="flex w-full items-center gap-1 text-mineshaft-100">
{showCredential ? children : "****************************"}
<Tooltip content={`Copy ${label}`}>
<IconButton
onClick={() => {
setCopyCredential(children);
navigator.clipboard.writeText(children);
}}
ariaLabel="Copy credential"
variant="plain"
size="xs"
>
<FontAwesomeIcon icon={isCopyingCredential ? faCheck : faCopy} />
</IconButton>
</Tooltip>
{isSensitive && (
<Tooltip content={`Show ${label}`}>
<IconButton
variant="plain"
size="xs"
onClick={toggleShowCredential}
ariaLabel={`${showCredential ? "Hide" : "Show"} credential`}
>
<FontAwesomeIcon icon={faEyeSlash} />
</IconButton>
</Tooltip>
)}
</div>
) : null}
</GenericFieldLabel>
);
};
@@ -0,0 +1,57 @@
import { faCheck, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay";
import { TViewSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2";
type Props = {
generatedCredentialsResponse: TViewSecretRotationGeneratedCredentialsResponse;
};
export const ViewSqlRotationGeneratedCredentials = ({
generatedCredentialsResponse: { generatedCredentials, activeIndex }
}: Props) => {
const inactiveIndex = activeIndex === 0 ? 1 : 0;
const activeCredentials = generatedCredentials[activeIndex];
const inactiveCredentials = generatedCredentials[inactiveIndex];
return (
<>
<div className="flex flex-col gap-2">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-100">
<FontAwesomeIcon icon={faCheck} className="mr-1.5 text-green" />
Current Credentials
</span>
</div>
<p className="text-sm text-mineshaft-300">
The active credential set currently mapped to the rotation secrets.
</p>
<div className="flex flex-col gap-x-8 gap-y-2 rounded border border-mineshaft-600 bg-mineshaft-700 p-2">
<CredentialDisplay label="Username">{activeCredentials?.username}</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{activeCredentials?.password}
</CredentialDisplay>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-100">
<FontAwesomeIcon icon={faClockRotateLeft} className="mr-1.5 text-yellow" />
Retired Credentials
</span>
</div>
<p className="text-sm text-mineshaft-300">
The retired credential set that will be revoked during the next rotation cycle.
</p>
<div className="flex flex-col gap-x-8 gap-y-2 rounded border border-mineshaft-600 bg-mineshaft-700 p-2">
<CredentialDisplay label="Username">{inactiveCredentials?.username}</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{inactiveCredentials?.password}
</CredentialDisplay>
</div>
</div>
</>
);
};
@@ -0,0 +1 @@
export * from "./ViewSqlRotationGeneratedCredentials";
@@ -0,0 +1,124 @@
import { Controller, useFormContext } from "react-hook-form";
import { format, setHours, setMinutes } from "date-fns";
import { FilterableSelect, FormControl, Input, Switch } from "@app/components/v2";
import { getRotateAtLocal } from "@app/helpers/secretRotationsV2";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
import { TSecretRotationV2Form } from "./schemas";
import { SecretRotationV2ConnectionField } from "./SecretRotationV2ConnectionField";
type Props = {
isUpdate: boolean;
environments?: WorkspaceEnv[];
};
export const SecretRotationV2ConfigurationFields = ({ isUpdate, environments }: Props) => {
const { control, watch } = useFormContext<TSecretRotationV2Form>();
console.log(watch("rotateAtUtc"));
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Configure the connection rotation strategy for this Secret Rotation.
</p>
{!isUpdate && environments && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl label="Environment" isError={Boolean(error)} errorText={error?.message}>
<FilterableSelect
value={value}
onChange={onChange}
options={environments}
placeholder="Select an environment..."
getOptionLabel={(option) => option?.name}
getOptionValue={(option) => option?.id}
/>
</FormControl>
)}
/>
)}
<SecretRotationV2ConnectionField isUpdate={isUpdate} />
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Rotation Interval (In Days)"
>
<Input
value={value}
type="number"
onChange={onChange}
min={1}
placeholder="my-secret-rotation"
/>
</FormControl>
)}
control={control}
name="rotationInterval"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
label="Rotate At (Local Time)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
type="time"
value={format(getRotateAtLocal(value), "HH:mm")}
onChange={(e) => {
const time = e.target.value;
if (time) {
const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10));
const newSelectedDate = setHours(setMinutes(new Date(), minutes), hours);
onChange({
hours: newSelectedDate.getUTCHours(),
minutes: newSelectedDate.getUTCMinutes()
});
}
}}
className="bg-mineshaft-700 text-white [color-scheme:dark]"
/>
</FormControl>
);
}}
control={control}
name="rotateAtUtc"
/>
<Controller
control={control}
name="isAutoRotationEnabled"
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
helperText={
value
? "Secrets will automatically be rotated when the rotation interval specified above as elapsed."
: "Secrets will not be rotated automatically. You can still rotate secrets manually."
}
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="auto-rotation-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p className="w-[9.6rem]">Auto-Rotation {value ? "Enabled" : "Disabled"}</p>
</Switch>
</FormControl>
);
}}
/>
</>
);
};
@@ -0,0 +1,85 @@
import { Controller, useFormContext } from "react-hook-form";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { OrgPermissionSubjects, useOrgPermission } from "@app/context";
import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { SECRET_ROTATION_CONNECTION_MAP } from "@app/helpers/secretRotationsV2";
import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
import { TSecretRotationV2Form } from "./schemas";
type Props = {
onChange?: VoidFunction;
isUpdate: boolean;
};
export const SecretRotationV2ConnectionField = ({ onChange: callback, isUpdate }: Props) => {
const { permission } = useOrgPermission();
const { control, watch } = useFormContext<TSecretRotationV2Form>();
const rotationType = watch("type");
const app = SECRET_ROTATION_CONNECTION_MAP[rotationType];
const { data: availableConnections, isPending } = useListAvailableAppConnections(app);
const connectionName = APP_CONNECTION_MAP[app].name;
const canCreateConnection = permission.can(
OrgPermissionAppConnectionActions.Create,
OrgPermissionSubjects.AppConnections
);
const appName = APP_CONNECTION_MAP[app].name;
return (
<>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="App Connections can be created from the Organization Settings page."
isError={Boolean(error)}
errorText={error?.message}
label={`${connectionName} Connection`}
helperText={isUpdate ? "Cannot be updated" : undefined}
>
<FilterableSelect
value={value}
onChange={(newValue) => {
onChange(newValue);
if (callback) callback();
}}
isLoading={isPending}
options={availableConnections}
isDisabled={isUpdate}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
control={control}
name="connection"
/>
{!isUpdate && availableConnections?.length === 0 && (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faInfoCircle} />
{canCreateConnection ? (
<>
You do not have access to any {appName} Connections. Create one from the{" "}
<Link to="/organization/app-connections" className="underline">
App Connections
</Link>{" "}
page.
</>
) : (
`You do not have access to any ${appName} Connections. Contact an admin to create one.`
)}
</p>
)}
</>
);
};
@@ -0,0 +1,51 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Input, TextArea } from "@app/components/v2";
import { TSecretRotationV2Form } from "./schemas";
export const SecretRotationV2DetailsFields = () => {
const { control } = useFormContext<TSecretRotationV2Form>();
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Provide a name and description for this Secret Rotation.
</p>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
helperText="Must be slug-friendly"
isError={Boolean(error)}
errorText={error?.message}
label="Name"
>
<Input autoFocus value={value} onChange={onChange} placeholder="my-secret-rotation" />
</FormControl>
)}
control={control}
name="name"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
isOptional
errorText={error?.message}
label="Description"
>
<TextArea
value={value ?? ""}
onChange={onChange}
placeholder="Describe the purpose of this rotation..."
className="!resize-none"
rows={4}
/>
</FormControl>
)}
control={control}
name="description"
/>
</>
);
};
@@ -0,0 +1,231 @@
import { 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 { SecretRotationV2ConfigurationFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields";
import { SecretRotationV2DetailsFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields";
import { SecretRotationV2ParametersFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields";
import { SecretRotationV2ReviewFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields";
import { SecretRotationV2SecretsMappingFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields";
import { Button } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import {
SecretRotation,
TSecretRotationV2,
useSecretRotationV2Option
} from "@app/hooks/api/secretRotationsV2";
import {
useCreateSecretRotationV2,
useUpdateSecretRotationV2
} from "@app/hooks/api/secretRotationsV2/mutations";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
import { SecretRotationV2FormSchema, TSecretRotationV2Form } from "./schemas";
type Props = {
onComplete: (secretRotation: TSecretRotationV2) => void;
type: SecretRotation;
onCancel: () => void;
secretPath: string;
environment?: string;
environments?: WorkspaceEnv[];
secretRotation?: TSecretRotationV2;
};
const FORM_TABS: { name: string; key: string; fields: (keyof TSecretRotationV2Form)[] }[] = [
{
name: "Configuration",
key: "configuration",
fields: [
"isAutoRotationEnabled",
"environment",
"rotationInterval",
"connection",
"rotateAtUtc"
]
},
{ name: "Parameters", key: "parameters", fields: ["parameters"] },
{ name: "Mappings", key: "secretsMapping", fields: ["secretsMapping"] },
{ name: "Details", key: "details", fields: ["name", "description"] },
{ name: "Review", key: "review", fields: [] }
];
const DEFAULT_ROTATION_INTERVAL = 30;
export const SecretRotationV2Form = ({
type,
onComplete,
onCancel,
environment: envSlug,
secretPath,
secretRotation,
environments
}: Props) => {
const createSecretRotation = useCreateSecretRotationV2();
const updateSecretRotation = useUpdateSecretRotationV2();
const { currentWorkspace } = useWorkspace();
const { name: rotationType } = SECRET_ROTATION_MAP[type];
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const { rotationOption } = useSecretRotationV2Option(type);
const formMethods = useForm<TSecretRotationV2Form>({
resolver: zodResolver(SecretRotationV2FormSchema),
defaultValues: secretRotation
? {
...secretRotation,
environment: currentWorkspace?.environments.find((env) => env.slug === envSlug),
secretPath
}
: {
type,
isAutoRotationEnabled: true,
rotationInterval: DEFAULT_ROTATION_INTERVAL,
rotateAtUtc: {
hours: 0,
minutes: 0
},
environment: currentWorkspace?.environments.find((env) => env.slug === envSlug),
secretPath,
...rotationOption!.template
},
reValidateMode: "onChange"
});
const onSubmit = async ({
environment,
connection,
...formData
}: TSecretRotationV2Form) => {
const mutation = secretRotation
? updateSecretRotation.mutateAsync({
rotationId: secretRotation.id,
projectId: secretRotation.projectId,
...formData
})
: createSecretRotation.mutateAsync({
...formData,
connectionId: connection.id,
environment: environment.slug,
projectId: currentWorkspace.id
});
try {
const rotation = await mutation;
createNotification({
text: `Successfully ${secretRotation ? "updated" : "created"} ${rotationType} Rotation`,
type: "success"
});
onComplete(rotation);
} catch (err: any) {
createNotification({
title: `Failed to ${secretRotation ? "update" : "create"} ${rotationType} Rotation`,
text: err.message,
type: "error"
});
}
};
const handlePrev = () => {
if (selectedTabIndex === 0) {
onCancel();
return;
}
setSelectedTabIndex((prev) => prev - 1);
};
const { handleSubmit, trigger } = formMethods;
const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
const handleNext = async () => {
if (isFinalStep) {
handleSubmit(onSubmit)();
return;
}
const isValid = await isStepValid(selectedTabIndex);
if (!isValid) return;
setSelectedTabIndex((prev) => prev + 1);
};
const isTabEnabled = async (index: number) => {
let isEnabled = true;
for (let i = index - 1; i >= 0; i -= 1) {
// eslint-disable-next-line no-await-in-loop
isEnabled = isEnabled && (await isStepValid(i));
}
return isEnabled;
};
return (
<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 }) =>
`w-30 -mb-[0.14rem] ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-none 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>
<Tab.Panel>
<SecretRotationV2ConfigurationFields
isUpdate={Boolean(secretRotation)}
environments={environments}
/>
</Tab.Panel>
<Tab.Panel>
<SecretRotationV2ParametersFields />
</Tab.Panel>
<Tab.Panel>
<SecretRotationV2SecretsMappingFields />
</Tab.Panel>
<Tab.Panel>
<SecretRotationV2DetailsFields />
</Tab.Panel>
<Tab.Panel>
<SecretRotationV2ReviewFields />
</Tab.Panel>
</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 ? `${secretRotation ? "Update" : "Create"} Secret Rotation` : "Next"}
</Button>
<Button onClick={handlePrev} colorSchema="secondary">
Back
</Button>
</div>
</form>
);
};
@@ -0,0 +1,28 @@
import { useFormContext } from "react-hook-form";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { TSecretRotationV2Form } from "../schemas";
import { SqlRotationParametersFields } from "./shared";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlRotationParametersFields,
[SecretRotation.MsSqlCredentials]: SqlRotationParametersFields
};
export const SecretRotationV2ParametersFields = () => {
const { watch } = useFormContext<TSecretRotationV2Form>();
const rotationType = watch("type");
const Component = COMPONENT_MAP[rotationType];
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Configure the required parameters for this Secret Rotation.
</p>
<Component />
</>
);
};
@@ -0,0 +1 @@
export * from "./SecretRotationV2ParametersFields";
@@ -0,0 +1,60 @@
import { Controller, useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { FormControl, Input } from "@app/components/v2";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2";
export const SqlRotationParametersFields = () => {
const { control, watch } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.PostgresCredentials; // all sql rotations share these fields
}
>();
const type = watch("type");
const { rotationOption } = useSecretRotationV2Option(type);
return (
<>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Database Username 1"
>
<Input value={value} onChange={onChange} placeholder="infiscal_user_1" />
</FormControl>
)}
control={control}
name="parameters.username1"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Database Username 2"
>
<Input value={value} onChange={onChange} placeholder="infiscal_user_2" />
</FormControl>
)}
control={control}
name="parameters.username2"
/>
<NoticeBannerV2 title="Example Create User Statement">
<p className="mb-3 text-sm text-mineshaft-300">
Infisical requires two database users to be created for rotation. Below is an example
statement for creating the required users. You may need to modify it to suit your needs.
</p>
<p className="text-sm">
<pre className="whitespace-pre-wrap rounded border border-mineshaft-700 bg-mineshaft-800 p-2 text-mineshaft-300">
{rotationOption!.template.createUserStatement}
</pre>
</p>
</NoticeBannerV2>
</>
);
};
@@ -0,0 +1 @@
export * from "./SqlRotationParametersFields";
@@ -0,0 +1,61 @@
import { useFormContext } from "react-hook-form";
import { format } from "date-fns";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { SqlRotationReviewFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared";
import { GenericFieldLabel } from "@app/components/v2";
import { getRotateAtLocal } from "@app/helpers/secretRotationsV2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlRotationReviewFields,
[SecretRotation.MsSqlCredentials]: SqlRotationReviewFields
};
export const SecretRotationV2ReviewFields = () => {
const { watch } = useFormContext<TSecretRotationV2Form>();
const {
environment,
secretPath,
connection,
type,
name,
description,
rotationInterval,
rotateAtUtc
} = watch();
const Component = COMPONENT_MAP[type];
return (
<div className="mb-4 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Configuration</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<GenericFieldLabel label="Connection">{connection.name}</GenericFieldLabel>
<GenericFieldLabel label="Environment">{environment.name}</GenericFieldLabel>
<GenericFieldLabel label="Secret Path">{secretPath}</GenericFieldLabel>
<GenericFieldLabel label="Rotation Interval">
{rotationInterval} Day{rotationInterval > 1 ? "s" : ""}
</GenericFieldLabel>
<GenericFieldLabel label="Rotate At">
{format(getRotateAtLocal(rotateAtUtc), "h:mm aa")}
</GenericFieldLabel>
</div>
</div>
<Component />
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Details</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description}</GenericFieldLabel>
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from "./SecretRotationReviewFields";
@@ -0,0 +1,41 @@
import { useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const SqlRotationReviewFields = () => {
const { watch } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.PostgresCredentials; // all sql rotations share these fields
}
>();
const [{ username1, username2 }, { username, password }] = watch([
"parameters",
"secretsMapping"
]);
return (
<>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Parameters</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<GenericFieldLabel label="Database Username 1">{username1}</GenericFieldLabel>
<GenericFieldLabel label="Database Username 2">{username2}</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">Secrets Mapping</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<GenericFieldLabel label="Username">{username}</GenericFieldLabel>
<GenericFieldLabel label="Password">{password}</GenericFieldLabel>
</div>
</div>
</>
);
};
@@ -0,0 +1 @@
export * from "./SqlRotationReviewFields";
@@ -0,0 +1,28 @@
import { useFormContext } from "react-hook-form";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { TSecretRotationV2Form } from "../schemas";
import { SqlRotationSecretsMappingFields } from "./shared";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlRotationSecretsMappingFields,
[SecretRotation.MsSqlCredentials]: SqlRotationSecretsMappingFields
};
export const SecretRotationV2SecretsMappingFields = () => {
const { watch } = useFormContext<TSecretRotationV2Form>();
const rotationType = watch("type");
const Component = COMPONENT_MAP[rotationType];
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Map the rotated credentials to secrets in your Infisical project.
</p>
<Component />
</>
);
};
@@ -0,0 +1 @@
export * from "./SecretRotationV2SecretsMappingFields";
@@ -0,0 +1,88 @@
import { Controller, useFormContext } from "react-hook-form";
import { faArrowRight, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { Badge, FormControl, FormLabel, Input } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const SqlRotationSecretsMappingFields = () => {
const { control } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.PostgresCredentials; // all sql rotations share these fields
}
>();
const items = [
{
name: "Username",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input value={value} onChange={onChange} placeholder="DB_USERNAME" />
</FormControl>
)}
control={control}
name="secretsMapping.username"
/>
)
},
{
name: "Password",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input value={value} onChange={onChange} placeholder="DB_PASSWORD" />
</FormControl>
)}
control={control}
name="secretsMapping.password"
/>
)
}
];
return (
<div className="w-full overflow-hidden">
<table className="w-full table-auto">
<thead>
<tr className="text-left">
<th className="whitespace-nowrap">
<FormLabel label="Rotated Credentials" />
</th>
<th />
<th>
<FormLabel
tooltipClassName="max-w-sm"
tooltipText="The name of the secret that the active credentials will be mapped to."
label="Secret Name"
/>
</th>
</tr>
</thead>
<tbody>
{items.map(({ name, input }) => (
<tr key={name}>
<td className="whitespace-nowrap">
<div className="mb-4 flex h-full items-start justify-center">
<Badge className="pointer-events-none flex h-[36px] w-full items-center justify-center gap-1.5 whitespace-nowrap border border-mineshaft-600 bg-mineshaft-600 text-bunker-200">
<FontAwesomeIcon icon={faKey} />
<span>{name}</span>
</Badge>
</div>
</td>
<td className="whitespace-nowrap pl-5 pr-5">
<div className="mb-4 flex items-center justify-center">
<FontAwesomeIcon className="text-mineshaft-400" icon={faArrowRight} />
</div>
</td>
<td className="w-full">{input}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
@@ -0,0 +1 @@
export * from "./SqlRotationSecretsMappingFields";
@@ -0,0 +1 @@
export * from "./SecretRotationV2Form";
@@ -0,0 +1,17 @@
import { z } from "zod";
import { slugSchema } from "@app/lib/schemas";
export const BaseSecretRotationSchema = z.object({
name: slugSchema({ field: "Name" }),
description: z.string().trim().max(256, "Cannot exceed 256 characters").nullish(),
connection: z.object({ name: z.string(), id: z.string().uuid() }),
environment: z.object({ slug: z.string(), id: z.string(), name: z.string() }),
secretPath: z.string().min(1, "Secret path required"),
isAutoRotationEnabled: z.boolean(),
rotationInterval: z.coerce.number(),
rotateAtUtc: z.object({
hours: z.number(),
minutes: z.number()
})
});
@@ -0,0 +1,13 @@
import { z } from "zod";
import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema";
import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema";
const SecretRotationUnionSchema = z.discriminatedUnion("type", [
PostgresCredentialsRotationSchema,
MsSqlCredentialsRotationSchema
]);
export const SecretRotationV2FormSchema = SecretRotationUnionSchema;
export type TSecretRotationV2Form = z.infer<typeof SecretRotationV2FormSchema>;
@@ -0,0 +1,12 @@
import { z } from "zod";
import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
import { SqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const MsSqlCredentialsRotationSchema = z
.object({
type: z.literal(SecretRotation.MsSqlCredentials)
})
.merge(SqlCredentialsRotationSchema)
.merge(BaseSecretRotationSchema);
@@ -0,0 +1,12 @@
import { z } from "zod";
import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
import { SqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const PostgresCredentialsRotationSchema = z
.object({
type: z.literal(SecretRotation.PostgresCredentials)
})
.merge(SqlCredentialsRotationSchema)
.merge(BaseSecretRotationSchema);
@@ -0,0 +1 @@
export * from "./sql-credentials-rotation-schema";
@@ -0,0 +1,14 @@
import { z } from "zod";
import { SecretNameSchema } from "@app/lib/schemas";
export const SqlCredentialsRotationSchema = z.object({
parameters: z.object({
username1: z.string().trim().min(1, "Database Username 1 Required"),
username2: z.string().trim().min(1, "Database Username 2 Required")
}),
secretsMapping: z.object({
username: SecretNameSchema,
password: SecretNameSchema
})
});
@@ -0,0 +1 @@
export * from "./CreateSecretRotationV2Modal";
@@ -2,7 +2,7 @@ import { useFormContext } from "react-hook-form";
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
@@ -17,9 +17,9 @@ export const AwsParameterStoreSyncOptionsReviewFields = () => {
return (
<>
{keyId && <SecretSyncLabel label="KMS Key">{keyId}</SecretSyncLabel>}
{keyId && <GenericFieldLabel label="KMS Key">{keyId}</GenericFieldLabel>}
{tags && tags.length > 0 && (
<SecretSyncLabel label="Resource Tags">
<GenericFieldLabel label="Resource Tags">
<Tooltip
side="right"
className="max-w-xl p-1"
@@ -49,12 +49,12 @@ export const AwsParameterStoreSyncOptionsReviewFields = () => {
</Badge>
</div>
</Tooltip>
</SecretSyncLabel>
</GenericFieldLabel>
)}
{syncSecretMetadataAsTags && (
<SecretSyncLabel label="Sync Secret Metadata as Resource Tags">
<GenericFieldLabel label="Sync Secret Metadata as Resource Tags">
<Badge variant="success">Enabled</Badge>
</SecretSyncLabel>
</GenericFieldLabel>
)}
</>
);
@@ -71,13 +71,13 @@ export const AwsParameterStoreDestinationReviewFields = () => {
return (
<>
<SecretSyncLabel label="Region">
<GenericFieldLabel label="Region">
{awsRegion?.name}
<Badge className="ml-1" variant="success">
{awsRegion?.slug}{" "}
</Badge>
</SecretSyncLabel>
<SecretSyncLabel label="Path">{path}</SecretSyncLabel>
</GenericFieldLabel>
<GenericFieldLabel label="Path">{path}</GenericFieldLabel>
</>
);
};
@@ -2,7 +2,7 @@ import { useFormContext } from "react-hook-form";
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
@@ -24,17 +24,17 @@ export const AwsSecretsManagerSyncReviewFields = () => {
return (
<>
<SecretSyncLabel label="Region">
<GenericFieldLabel label="Region">
{awsRegion?.name}
<Badge className="ml-1" variant="success">
{awsRegion?.slug}{" "}
</Badge>
</SecretSyncLabel>
<SecretSyncLabel className="capitalize" label="Mapping Behavior">
</GenericFieldLabel>
<GenericFieldLabel className="capitalize" label="Mapping Behavior">
{mappingBehavior}
</SecretSyncLabel>
</GenericFieldLabel>
{mappingBehavior === AwsSecretsManagerSyncMappingBehavior.ManyToOne && (
<SecretSyncLabel label="Secret Name">{secretName}</SecretSyncLabel>
<GenericFieldLabel label="Secret Name">{secretName}</GenericFieldLabel>
)}
</>
);
@@ -49,9 +49,9 @@ export const AwsSecretsManagerSyncOptionsReviewFields = () => {
return (
<>
{keyId && <SecretSyncLabel label="KMS Key">{keyId}</SecretSyncLabel>}
{keyId && <GenericFieldLabel label="KMS Key">{keyId}</GenericFieldLabel>}
{tags && tags.length > 0 && (
<SecretSyncLabel label="Tags">
<GenericFieldLabel label="Tags">
<Tooltip
side="right"
className="max-w-xl p-1"
@@ -81,12 +81,12 @@ export const AwsSecretsManagerSyncOptionsReviewFields = () => {
</Badge>
</div>
</Tooltip>
</SecretSyncLabel>
</GenericFieldLabel>
)}
{syncSecretMetadataAsTags && (
<SecretSyncLabel label="Sync Secret Metadata as Resource Tags">
<GenericFieldLabel label="Sync Secret Metadata as Resource Tags">
<Badge variant="success">Enabled</Badge>
</SecretSyncLabel>
</GenericFieldLabel>
)}
</>
);
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -13,8 +13,8 @@ export const AzureAppConfigurationSyncReviewFields = () => {
return (
<>
<SecretSyncLabel label="Configuration URL">{vaultBaseUrl}</SecretSyncLabel>
<SecretSyncLabel label="Label">{label}</SecretSyncLabel>
<GenericFieldLabel label="Configuration URL">{vaultBaseUrl}</GenericFieldLabel>
<GenericFieldLabel label="Label">{label}</GenericFieldLabel>
</>
);
};
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -8,5 +8,5 @@ export const AzureKeyVaultSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.AzureKeyVault }>();
const vaultBaseUrl = watch("destinationConfig.vaultBaseUrl");
return <SecretSyncLabel label="Vault URL">{vaultBaseUrl}</SecretSyncLabel>;
return <GenericFieldLabel label="Vault URL">{vaultBaseUrl}</GenericFieldLabel>;
};
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -8,5 +8,5 @@ export const DatabricksSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Databricks }>();
const scope = watch("destinationConfig.scope");
return <SecretSyncLabel label="Secret Scope">{scope}</SecretSyncLabel>;
return <GenericFieldLabel label="Secret Scope">{scope}</GenericFieldLabel>;
};
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -10,5 +10,5 @@ export const GcpSyncReviewFields = () => {
>();
const projectId = watch("destinationConfig.projectId");
return <SecretSyncLabel label="Project ID">{projectId}</SecretSyncLabel>;
return <GenericFieldLabel label="Project ID">{projectId}</GenericFieldLabel>;
};
@@ -1,7 +1,7 @@
import { ReactNode } from "react";
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { GitHubSyncScope, TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync";
@@ -16,30 +16,30 @@ export const GitHubSyncReviewFields = () => {
switch (config.scope) {
case GitHubSyncScope.Repository:
ScopeComponents = (
<SecretSyncLabel label="Repository">
<GenericFieldLabel label="Repository">
{config.owner}/{config.repo}
</SecretSyncLabel>
</GenericFieldLabel>
);
break;
case GitHubSyncScope.Organization:
ScopeComponents = (
<>
<SecretSyncLabel label="Organization">{config.org}</SecretSyncLabel>
<SecretSyncLabel className="capitalize" label="Visibility">
<GenericFieldLabel label="Organization">{config.org}</GenericFieldLabel>
<GenericFieldLabel className="capitalize" label="Visibility">
{config.visibility}
</SecretSyncLabel>
</GenericFieldLabel>
</>
);
break;
case GitHubSyncScope.RepositoryEnvironment:
ScopeComponents = (
<>
<SecretSyncLabel label="Repository">
<GenericFieldLabel label="Repository">
{config.owner}/{config.repo}
</SecretSyncLabel>
<SecretSyncLabel className="capitalize" label="Environment">
</GenericFieldLabel>
<GenericFieldLabel className="capitalize" label="Environment">
{config.env}
</SecretSyncLabel>
</GenericFieldLabel>
</>
);
@@ -52,9 +52,9 @@ export const GitHubSyncReviewFields = () => {
return (
<>
<SecretSyncLabel className="capitalize" label="Scope">
<GenericFieldLabel className="capitalize" label="Scope">
{config.scope.replace("-", " ")}
</SecretSyncLabel>
</GenericFieldLabel>
{ScopeComponents}
</>
);
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync";
@@ -14,10 +14,10 @@ export const HumanitecSyncReviewFields = () => {
return (
<>
<SecretSyncLabel label="Organization">{orgId}</SecretSyncLabel>
<SecretSyncLabel label="Application">{appId}</SecretSyncLabel>
<GenericFieldLabel label="Organization">{orgId}</GenericFieldLabel>
<GenericFieldLabel label="Application">{appId}</GenericFieldLabel>
{scope === HumanitecSyncScope.Environment && (
<SecretSyncLabel label="Environment">{envId}</SecretSyncLabel>
<GenericFieldLabel label="Environment">{envId}</GenericFieldLabel>
)}
</>
);
@@ -1,7 +1,7 @@
import { ReactNode } from "react";
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge } from "@app/components/v2";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
@@ -83,8 +83,8 @@ export const SecretSyncReviewFields = () => {
<span className="text-sm text-mineshaft-300">Source</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Environment">{environment.name}</SecretSyncLabel>
<SecretSyncLabel label="Secret Path">{secretPath}</SecretSyncLabel>
<GenericFieldLabel label="Environment">{environment.name}</GenericFieldLabel>
<GenericFieldLabel label="Secret Path">{secretPath}</GenericFieldLabel>
</div>
</div>
<div className="flex flex-col gap-3">
@@ -92,7 +92,7 @@ export const SecretSyncReviewFields = () => {
<span className="text-sm text-mineshaft-300">Destination</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Connection">{connection.name}</SecretSyncLabel>
<GenericFieldLabel label="Connection">{connection.name}</GenericFieldLabel>
{DestinationFieldsComponent}
</div>
</div>
@@ -101,21 +101,21 @@ export const SecretSyncReviewFields = () => {
<span className="text-sm text-mineshaft-300">Sync Options</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Auto-Sync">
<GenericFieldLabel label="Auto-Sync">
<Badge variant={isAutoSyncEnabled ? "success" : "danger"}>
{isAutoSyncEnabled ? "Enabled" : "Disabled"}
</Badge>
</SecretSyncLabel>
<SecretSyncLabel label="Initial Sync Behavior">
</GenericFieldLabel>
<GenericFieldLabel label="Initial Sync Behavior">
{SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destinationName).name}
</SecretSyncLabel>
</GenericFieldLabel>
{/* <SecretSyncLabel label="Prepend Prefix">{prependPrefix}</SecretSyncLabel>
<SecretSyncLabel label="Append Suffix">{appendSuffix}</SecretSyncLabel> */}
{AdditionalSyncOptionsFieldsComponent}
{disableSecretDeletion && (
<SecretSyncLabel label="Secret Deletion">
<GenericFieldLabel label="Secret Deletion">
<Badge variant="primary">Disabled</Badge>
</SecretSyncLabel>
</GenericFieldLabel>
)}
</div>
</div>
@@ -124,8 +124,8 @@ export const SecretSyncReviewFields = () => {
<span className="text-sm text-mineshaft-300">Details</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Name">{name}</SecretSyncLabel>
<SecretSyncLabel label="Description">{description}</SecretSyncLabel>
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description}</GenericFieldLabel>
</div>
</div>
</div>
@@ -1,9 +1,9 @@
export * from "../v2/GenericFieldLabel";
export * from "./CreateSecretSyncModal";
export * from "./DeleteSecretSyncModal";
export * from "./EditSecretSyncModal";
export * from "./SecretSyncImportSecretsModal";
export * from "./SecretSyncImportStatusBadge";
export * from "./SecretSyncLabel";
export * from "./SecretSyncRemoveSecretsModal";
export * from "./SecretSyncRemoveStatusBadge";
export * from "./SecretSyncStatusBadge";
+1 -1
View File
@@ -9,7 +9,7 @@ interface IProps {
export const Blur = ({ className, tooltipText }: IProps) => {
return (
<Tooltip content={tooltipText} isDisabled={!tooltipText}>
<Tooltip content={tooltipText} className="max-w-md" isDisabled={!tooltipText}>
<div
className={twMerge("flex w-80 flex-grow items-center py-1 pl-4 pr-2", className)}
tabIndex={0}
@@ -1,14 +1,17 @@
import { ChangeEventHandler, useState } from "react";
import { DayPicker, DayPickerProps } from "react-day-picker";
import { DayPicker, DayPickerProps, getDefaultClassNames, UI } from "react-day-picker";
import { faCalendar } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverContentProps, PopoverProps } from "@radix-ui/react-popover";
import { format, setHours, setMinutes } from "date-fns";
import { twMerge } from "tailwind-merge";
import { Button } from "../Button";
import { Input } from "../Input";
import { Popover, PopoverContent, PopoverTrigger } from "../Popoverv2";
const defaultClassNames = getDefaultClassNames();
export type DatePickerProps = Omit<DayPickerProps, "selected"> & {
value?: Date;
onChange: (date?: Date) => void;
@@ -58,7 +61,30 @@ export const DatePicker = ({
{value ? format(value, dateFormat) : "Pick a date and time"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit p-2" {...popUpContentProps}>
<PopoverContent
{...popUpContentProps}
className={twMerge(
"w-fit border border-mineshaft-600 bg-mineshaft-800 p-2 font-inter",
popUpContentProps.className
)}
>
<div className="px-2 pt-4">
<DayPicker
{...props}
mode="single"
selected={value}
onSelect={handleDaySelect}
className="font-inter text-mineshaft-200"
classNames={{
today: "text-primary border-primary",
selected: " text-mineshaft-100 bg-mineshaft-500",
root: `text-mineshaft-300 ${defaultClassNames}`,
[UI.DayButton]: "p-3 rounded hover:text-mineshaft-100",
[UI.Weekday]: "px-3 pt-3",
[UI.Chevron]: "fill-mineshaft-300"
}}
/>
</div>
<div className="mx-4 my-4">
<Input
type="time"
@@ -67,17 +93,6 @@ export const DatePicker = ({
className="bg-mineshaft-700 text-white [color-scheme:dark]"
/>
</div>
<DayPicker
{...props}
mode="single"
selected={value}
onSelect={handleDaySelect}
modifiersStyles={{
selected: {
background: "#cad62d"
}
}}
/>
</PopoverContent>
</Popover>
);
@@ -8,7 +8,7 @@ type Props = {
labelClassName?: string;
};
export const SecretSyncLabel = ({ label, children, className, labelClassName }: Props) => {
export const GenericFieldLabel = ({ label, children, className, labelClassName }: Props) => {
return (
<div className={className}>
<p className={twMerge("text-xs font-medium text-mineshaft-400", labelClassName)}>{label}</p>
@@ -0,0 +1 @@
export * from "./GenericFieldLabel";
@@ -0,0 +1,20 @@
import { ReactNode } from "react";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props = {
title: string;
children: ReactNode;
};
export const NoticeBannerV2 = ({ title, children }: Props) => {
return (
<div className="flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
<div className="mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
{title}
</div>
{children}
</div>
);
};
+2
View File
@@ -16,12 +16,14 @@ export * from "./EmptyState";
export * from "./FilterableSelect";
export * from "./FontAwesomeSymbol";
export * from "./FormControl";
export * from "./GenericFieldLabel";
export * from "./HoverCardv2";
export * from "./IconButton";
export * from "./Input";
export * from "./Menu";
export * from "./Modal";
export * from "./NoticeBanner";
export * from "./NoticeBannerV2";
export * from "./PageHeader";
export * from "./Pagination";
export * from "./Popoverv2";
@@ -75,6 +75,15 @@ export enum ProjectPermissionGroupActions {
GrantPrivileges = "grant-privileges"
}
export enum ProjectPermissionSecretRotationActions {
Read = "read",
ReadGeneratedCredentials = "read-generated-credentials",
Create = "create",
Edit = "edit",
Delete = "delete",
RotateSecrets = "rotate-secrets"
}
export enum PermissionConditionOperators {
$IN = "$in",
$ALL = "$all",
@@ -169,6 +178,11 @@ export type SecretImportSubjectFields = {
secretPath: string;
};
export type SecretRotationSubjectFields = {
environment: string;
secretPath: string;
};
export type ProjectPermissionSet =
| [
ProjectPermissionSecretActions,
@@ -198,6 +212,13 @@ export type ProjectPermissionSet =
| (ForcedSubject<ProjectPermissionSub.SecretImports> & SecretImportSubjectFields)
)
]
| [
ProjectPermissionSecretRotationActions,
(
| ProjectPermissionSub.SecretRotation
| (ForcedSubject<ProjectPermissionSub.SecretRotation> & SecretRotationSubjectFields)
)
]
| [ProjectPermissionActions, ProjectPermissionSub.Role]
| [ProjectPermissionActions, ProjectPermissionSub.Tags]
| [ProjectPermissionActions, ProjectPermissionSub.Member]
@@ -210,7 +231,6 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
| [
ProjectPermissionActions,
(
+12 -5
View File
@@ -1,17 +1,19 @@
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import { faKey, faPassport, faUser } from "@fortawesome/free-solid-svg-icons";
import { faKey, faLock, faPassport, faUser } from "@fortawesome/free-solid-svg-icons";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
AwsConnectionMethod,
AzureAppConfigurationConnectionMethod,
AzureKeyVaultConnectionMethod,
DatabricksConnectionMethod,
GcpConnectionMethod,
GitHubConnectionMethod,
HumanitecConnectionMethod,
MsSqlConnectionMethod,
PostgresConnectionMethod,
TAppConnection
} from "@app/hooks/api/appConnections/types";
import { DatabricksConnectionMethod } from "@app/hooks/api/appConnections/types/databricks-connection";
import { HumanitecConnectionMethod } from "@app/hooks/api/appConnections/types/humanitec-connection";
export const APP_CONNECTION_MAP: Record<AppConnection, { name: string; image: string }> = {
[AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" },
@@ -26,7 +28,9 @@ export const APP_CONNECTION_MAP: Record<AppConnection, { name: string; image: st
image: "Microsoft Azure.png"
},
[AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" },
[AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" }
[AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" },
[AppConnection.Postgres]: { name: "PostgreSQL", image: "Postgres.png" },
[AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" }
};
export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => {
@@ -45,8 +49,11 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
return { name: "Service Account Impersonation", icon: faUser };
case DatabricksConnectionMethod.ServicePrincipal:
return { name: "Service Principal", icon: faUser };
case HumanitecConnectionMethod.API_TOKEN:
case HumanitecConnectionMethod.ApiToken:
return { name: "API Token", icon: faKey };
case PostgresConnectionMethod.UsernameAndPassword:
case MsSqlConnectionMethod.UsernameAndPassword:
return { name: "Username & Password", icon: faLock };
default:
throw new Error(`Unhandled App Connection Method: ${method}`);
}
+28
View File
@@ -0,0 +1,28 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretRotation, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
export const SECRET_ROTATION_MAP: Record<SecretRotation, { name: string; image: string }> = {
[SecretRotation.PostgresCredentials]: { name: "PostgreSQL Credentials", image: "Postgres.png" },
[SecretRotation.MsSqlCredentials]: {
name: "Microsoft SQL Server Credentials",
image: "MsSql.png"
}
};
export const SECRET_ROTATION_CONNECTION_MAP: Record<SecretRotation, AppConnection> = {
[SecretRotation.PostgresCredentials]: AppConnection.Postgres,
[SecretRotation.MsSqlCredentials]: AppConnection.MsSql
};
export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) =>
new Date(
Date.UTC(
new Date().getUTCFullYear(),
new Date().getUTCMonth(),
new Date().getUTCDate(),
hours,
minutes,
0,
0
)
);
@@ -5,5 +5,7 @@ export enum AppConnection {
AzureKeyVault = "azure-key-vault",
AzureAppConfiguration = "azure-app-configuration",
Databricks = "databricks",
Humanitec = "humanitec"
Humanitec = "humanitec",
Postgres = "postgres",
MsSql = "mssql"
}
@@ -3,6 +3,7 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
export type TAppConnectionOptionBase = {
name: string;
methods: string[];
supportsPlatformManagement?: boolean;
};
export type TAwsConnectionOption = TAppConnectionOptionBase & {
@@ -38,6 +39,14 @@ export type THumanitecConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Humanitec;
};
export type TPostgresConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Postgres;
};
export type TMsSqlConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.MsSql;
};
export type TAppConnectionOption =
| TAwsConnectionOption
| TGitHubConnectionOption
@@ -45,7 +54,9 @@ export type TAppConnectionOption =
| TAzureAppConfigurationConnectionOption
| TAzureKeyVaultConnectionOption
| TDatabricksConnectionOption
| THumanitecConnectionOption;
| THumanitecConnectionOption
| TPostgresConnectionOption
| TMsSqlConnectionOption;
export type TAppConnectionOptionMap = {
[AppConnection.AWS]: TAwsConnectionOption;
@@ -55,4 +66,6 @@ export type TAppConnectionOptionMap = {
[AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption;
[AppConnection.Databricks]: TDatabricksConnectionOption;
[AppConnection.Humanitec]: THumanitecConnectionOption;
[AppConnection.Postgres]: TPostgresConnectionOption;
[AppConnection.MsSql]: TMsSqlConnectionOption;
};
@@ -2,11 +2,11 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum HumanitecConnectionMethod {
API_TOKEN = "api-token"
ApiToken = "api-token"
}
export type THumanitecConnection = TRootAppConnection & { app: AppConnection.Humanitec } & {
method: HumanitecConnectionMethod.API_TOKEN;
method: HumanitecConnectionMethod.ApiToken;
credentials: {
apiToken: string;
};
@@ -1,20 +1,24 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TAppConnectionOption } from "@app/hooks/api/appConnections/types/app-options";
import { TAwsConnection } from "@app/hooks/api/appConnections/types/aws-connection";
import { TDatabricksConnection } from "@app/hooks/api/appConnections/types/databricks-connection";
import { TGitHubConnection } from "@app/hooks/api/appConnections/types/github-connection";
import { THumanitecConnection } from "@app/hooks/api/appConnections/types/humanitec-connection";
import { AppConnection } from "../enums";
import { TAppConnectionOption } from "./app-options";
import { TAwsConnection } from "./aws-connection";
import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection";
import { TAzureKeyVaultConnection } from "./azure-key-vault-connection";
import { TDatabricksConnection } from "./databricks-connection";
import { TGcpConnection } from "./gcp-connection";
import { TGitHubConnection } from "./github-connection";
import { THumanitecConnection } from "./humanitec-connection";
import { TMsSqlConnection } from "./mssql-connection";
import { TPostgresConnection } from "./postgres-connection";
export * from "./aws-connection";
export * from "./azure-app-configuration-connection";
export * from "./azure-key-vault-connection";
export * from "./databricks-connection";
export * from "./gcp-connection";
export * from "./github-connection";
export * from "./humanitec-connection";
export * from "./mssql-connection";
export * from "./postgres-connection";
export type TAppConnection =
| TAwsConnection
@@ -23,7 +27,9 @@ export type TAppConnection =
| TAzureKeyVaultConnection
| TAzureAppConfigurationConnection
| TDatabricksConnection
| THumanitecConnection;
| THumanitecConnection
| TPostgresConnection
| TMsSqlConnection;
export type TAvailableAppConnection = Pick<TAppConnection, "name" | "id">;
@@ -35,11 +41,11 @@ export type TAvailableAppConnectionsResponse = { appConnections: TAvailableAppCo
export type TCreateAppConnectionDTO = Pick<
TAppConnection,
"name" | "credentials" | "method" | "app" | "description"
"name" | "credentials" | "method" | "app" | "description" | "isPlatformManagedCredentials"
>;
export type TUpdateAppConnectionDTO = Partial<
Pick<TAppConnection, "name" | "credentials" | "description">
Pick<TAppConnection, "name" | "credentials" | "description" | "isPlatformManagedCredentials">
> & {
connectionId: string;
app: AppConnection;
@@ -58,4 +64,6 @@ export type TAppConnectionMap = {
[AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection;
[AppConnection.Databricks]: TDatabricksConnection;
[AppConnection.Humanitec]: THumanitecConnection;
[AppConnection.Postgres]: TPostgresConnection;
[AppConnection.MsSql]: TMsSqlConnection;
};
@@ -0,0 +1,13 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
import { TBaseSqlConnectionCredentials } from "./shared";
export enum MsSqlConnectionMethod {
UsernameAndPassword = "username-and-password"
}
export type TMsSqlConnection = TRootAppConnection & { app: AppConnection.MsSql } & {
method: MsSqlConnectionMethod.UsernameAndPassword;
credentials: TBaseSqlConnectionCredentials;
};
@@ -0,0 +1,13 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
import { TBaseSqlConnectionCredentials } from "./shared";
export enum PostgresConnectionMethod {
UsernameAndPassword = "username-and-password"
}
export type TPostgresConnection = TRootAppConnection & { app: AppConnection.Postgres } & {
method: PostgresConnectionMethod.UsernameAndPassword;
credentials: TBaseSqlConnectionCredentials;
};
@@ -6,4 +6,5 @@ export type TRootAppConnection = {
orgId: string;
createdAt: string;
updatedAt: string;
isPlatformManagedCredentials?: boolean;
};
@@ -0,0 +1 @@
export * from "./sql-connection";
@@ -0,0 +1,7 @@
export type TBaseSqlConnectionCredentials = {
host: string;
port: number;
username: string;
password: string;
database: string;
};
+10 -2
View File
@@ -110,7 +110,7 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.CREATE_APP_CONNECTION]: "Create App Connection",
[EventType.UPDATE_APP_CONNECTION]: "Update App Connection",
[EventType.DELETE_APP_CONNECTION]: "Delete App Connection",
[EventType.GET_SECRET_SYNCS]: "List Secret Syncs",
[EventType.GET_SECRET_SYNCS]: "List secret syncs",
[EventType.GET_SECRET_SYNC]: "Get Secret Sync",
[EventType.CREATE_SECRET_SYNC]: "Create Secret Sync",
[EventType.UPDATE_SECRET_SYNC]: "Update Secret Sync",
@@ -139,7 +139,15 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.KMIP_OPERATION_ACTIVATE]: "KMIP operation activate",
[EventType.KMIP_OPERATION_REVOKE]: "KMIP operation revoke",
[EventType.KMIP_OPERATION_LOCATE]: "KMIP operation locate",
[EventType.KMIP_OPERATION_REGISTER]: "KMIP operation register"
[EventType.KMIP_OPERATION_REGISTER]: "KMIP operation register",
[EventType.GET_SECRET_ROTATIONS]: "List Secret Rotations",
[EventType.GET_SECRET_ROTATION]: "Get Secret Rotation",
[EventType.GET_SECRET_ROTATION_GENERATED_CREDENTIALS]:
"Get Secret Rotation generated credentials",
[EventType.CREATE_SECRET_ROTATION]: "Create Secret Rotation",
[EventType.UPDATE_SECRET_ROTATION]: "Update Secret Rotation",
[EventType.DELETE_SECRET_ROTATION]: "Delete Secret Rotation",
[EventType.SECRET_ROTATION_ROTATE_SECRETS]: "Secret Rotation secrets rotated"
};
export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = {
+8 -1
View File
@@ -151,5 +151,12 @@ export enum EventType {
KMIP_OPERATION_REVOKE = "kmip-operation-revoke",
KMIP_OPERATION_LOCATE = "kmip-operation-locate",
KMIP_OPERATION_REGISTER = "kmip-operation-register",
SECRET_APPROVAL_REQUEST_REVIEW = "secret-approval-request-review"
SECRET_APPROVAL_REQUEST_REVIEW = "secret-approval-request-review",
GET_SECRET_ROTATIONS = "get-secret-rotations",
GET_SECRET_ROTATION = "get-secret-rotation",
GET_SECRET_ROTATION_GENERATED_CREDENTIALS = "get-secret-rotation-generated-credentials",
CREATE_SECRET_ROTATION = "create-secret-rotation",
UPDATE_SECRET_ROTATION = "update-secret-rotation",
DELETE_SECRET_ROTATION = "delete-secret-rotation",
SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets"
}
+43 -5
View File
@@ -132,6 +132,21 @@ export const fetchDashboardProjectSecretsByKeys = async ({
return data;
};
const mergePersonalRotationSecrets = (secrets: (SecretV3Raw | null)[]) => {
const actualSecrets: SecretV3Raw[] = [];
const dummySecrets: null[] = [];
secrets.forEach((secret) => {
if (secret !== null) {
actualSecrets.push(secret);
} else {
dummySecrets.push(secret);
}
});
return [...mergePersonalSecrets(actualSecrets), ...dummySecrets];
};
export const useGetProjectSecretsOverview = (
{
projectId,
@@ -145,6 +160,7 @@ export const useGetProjectSecretsOverview = (
includeFolders,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
environments
}: TGetDashboardProjectSecretsOverviewDTO,
options?: Omit<
@@ -173,6 +189,7 @@ export const useGetProjectSecretsOverview = (
includeFolders,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
environments
}),
queryFn: () =>
@@ -188,10 +205,11 @@ export const useGetProjectSecretsOverview = (
includeFolders,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
environments
}),
select: useCallback((data: Awaited<ReturnType<typeof fetchProjectSecretsOverview>>) => {
const { secrets, ...select } = data;
const { secrets, secretRotations, ...select } = data;
const uniqueSecrets = secrets ? unique(secrets, (i) => i.secretKey) : [];
const uniqueFolders = select.folders ? unique(select.folders, (i) => i.name) : [];
@@ -201,14 +219,22 @@ export const useGetProjectSecretsOverview = (
: [];
const uniqueSecretImports = select.imports ? unique(select.imports, (i) => i.id) : [];
const uniqueSecretRotations = secretRotations ? unique(secretRotations, (i) => i.name) : [];
return {
...select,
secrets: secrets ? mergePersonalSecrets(secrets) : undefined,
secretRotations: secretRotations?.map((rotation) => {
return {
...rotation,
secrets: mergePersonalRotationSecrets(rotation.secrets)
};
}),
totalUniqueSecretsInPage: uniqueSecrets.length,
totalUniqueDynamicSecretsInPage: uniqueDynamicSecrets.length,
totalUniqueFoldersInPage: uniqueFolders.length,
totalUniqueSecretImportsInPage: uniqueSecretImports.length
totalUniqueSecretImportsInPage: uniqueSecretImports.length,
totalUniqueSecretRotationsInPage: uniqueSecretRotations.length
};
}, []),
placeholderData: (previousData) => previousData
@@ -230,6 +256,7 @@ export const useGetProjectSecretsDetails = (
viewSecretValue,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
tags
}: TGetDashboardProjectSecretsDetailsDTO,
options?: Omit<
@@ -260,6 +287,7 @@ export const useGetProjectSecretsDetails = (
includeFolders,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
tags
}),
queryFn: () =>
@@ -277,12 +305,17 @@ export const useGetProjectSecretsDetails = (
includeFolders,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
tags
}),
select: useCallback(
(data: Awaited<ReturnType<typeof fetchProjectSecretsDetails>>) => ({
...data,
secrets: data.secrets ? mergePersonalSecrets(data.secrets) : undefined
secrets: data.secrets ? mergePersonalSecrets(data.secrets) : undefined,
secretRotations: data.secretRotations?.map((rotation) => ({
...rotation,
secrets: mergePersonalRotationSecrets(rotation.secrets)
}))
}),
[]
),
@@ -371,7 +404,7 @@ export const useGetProjectSecretsQuickSearch = (
tags
}),
select: useCallback((data: Awaited<ReturnType<typeof fetchProjectSecretsQuickSearch>>) => {
const { secrets, folders, dynamicSecrets } = data;
const { secrets, folders, dynamicSecrets, secretRotations } = data;
const groupedFolders = groupBy(folders, (folder) => folder.path);
const groupedSecrets = groupBy(
@@ -383,11 +416,16 @@ export const useGetProjectSecretsQuickSearch = (
(dynamicSecret) =>
`${dynamicSecret.path === "/" ? "" : dynamicSecret.path}/${dynamicSecret.name}`
);
const groupedRotations = groupBy(
secretRotations,
(rotation) => `${rotation.folder.path === "/" ? "" : rotation.folder.path}/${rotation.name}`
);
return {
folders: groupedFolders,
secrets: groupedSecrets,
dynamicSecrets: groupedDynamicSecrets
dynamicSecrets: groupedDynamicSecrets,
secretRotations: groupedRotations
};
}, []),
placeholderData: (previousData) => previousData
+20 -1
View File
@@ -3,6 +3,7 @@ import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { TSecretImport } from "@app/hooks/api/secretImports/types";
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { SecretV3Raw, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
export type DashboardProjectSecretsOverviewResponse = {
@@ -14,11 +15,16 @@ export type DashboardProjectSecretsOverviewResponse = {
totalFolderCount?: number;
totalDynamicSecretCount?: number;
totalImportCount?: number;
secretRotations?: (TSecretRotationV2 & {
secrets: (SecretV3Raw | null)[];
})[];
totalSecretRotationCount?: number;
totalCount: number;
totalUniqueSecretsInPage: number;
totalUniqueDynamicSecretsInPage: number;
totalUniqueFoldersInPage: number;
totalUniqueSecretImportsInPage: number;
totalUniqueSecretRotationsInPage: number;
};
export type DashboardProjectSecretsDetailsResponse = {
@@ -26,10 +32,14 @@ export type DashboardProjectSecretsDetailsResponse = {
folders?: TSecretFolder[];
dynamicSecrets?: TDynamicSecret[];
secrets?: SecretV3Raw[];
secretRotations?: (TSecretRotationV2 & {
secrets: (SecretV3Raw | null)[];
})[];
totalImportCount?: number;
totalFolderCount?: number;
totalDynamicSecretCount?: number;
totalSecretCount?: number;
totalSecretRotationCount?: number;
totalCount: number;
};
@@ -39,9 +49,12 @@ export type DashboardProjectSecretsByKeys = {
export type DashboardProjectSecretsOverview = Omit<
DashboardProjectSecretsOverviewResponse,
"secrets"
"secrets" | "secretRotations"
> & {
secrets?: SecretV3RawSanitized[];
secretRotations?: (TSecretRotationV2 & {
secrets: (SecretV3RawSanitized | null)[];
})[];
};
export type DashboardProjectSecretsDetails = Omit<
@@ -49,6 +62,9 @@ export type DashboardProjectSecretsDetails = Omit<
"secrets"
> & {
secrets?: SecretV3RawSanitized[];
secretRotations?: (TSecretRotationV2 & {
secrets: (SecretV3RawSanitized | null)[];
})[];
};
export enum DashboardSecretsOrderBy {
@@ -67,6 +83,7 @@ export type TGetDashboardProjectSecretsOverviewDTO = {
includeFolders?: boolean;
includeDynamicSecrets?: boolean;
includeImports?: boolean;
includeSecretRotations?: boolean;
environments: string[];
};
@@ -83,6 +100,7 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit<
export type TDashboardProjectSecretsQuickSearchResponse = {
folders: (TSecretFolder & { envId: string; path: string })[];
dynamicSecrets: (TDynamicSecret & { environment: string; path: string })[];
secretRotations: TSecretRotationV2[];
secrets: SecretV3Raw[];
};
@@ -90,6 +108,7 @@ export type TDashboardProjectSecretsQuickSearch = {
folders: Record<string, TDashboardProjectSecretsQuickSearchResponse["folders"]>;
secrets: Record<string, SecretV3RawSanitized[]>;
dynamicSecrets: Record<string, TDashboardProjectSecretsQuickSearchResponse["dynamicSecrets"]>;
secretRotations: Record<string, TDashboardProjectSecretsQuickSearchResponse["secretRotations"]>;
};
export type TGetDashboardProjectSecretsQuickSearchDTO = {
@@ -0,0 +1,9 @@
export enum SecretRotation {
PostgresCredentials = "postgres-credentials",
MsSqlCredentials = "mssql-credentials"
}
export enum SecretRotationStatus {
Success = "success",
Failed = "failed"
}
@@ -0,0 +1,3 @@
export * from "./enums";
export * from "./queries";
export * from "./types";
@@ -0,0 +1,91 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import {
TCreateSecretRotationV2DTO,
TDeleteSecretRotationV2DTO,
TRotateSecretRotationV2DTO,
TSecretRotationV2Response,
TUpdateSecretRotationV2DTO
} from "@app/hooks/api/secretRotationsV2/types";
export const useCreateSecretRotationV2 = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ type, ...params }: TCreateSecretRotationV2DTO) => {
const { data } = await apiRequest.post<TSecretRotationV2Response>(
`/api/v2/secret-rotations/${type}`,
params
);
return data.secretRotation;
},
onSuccess: (_, { projectId, secretPath }) =>
queryClient.invalidateQueries({
queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
})
});
};
export const useUpdateSecretRotationV2 = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ type, rotationId, ...params }: TUpdateSecretRotationV2DTO) => {
const { data } = await apiRequest.patch<TSecretRotationV2Response>(
`/api/v2/secret-rotations/${type}/${rotationId}`,
params
);
return data.secretRotation;
},
onSuccess: (_, { projectId, secretPath }) =>
queryClient.invalidateQueries({
queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
})
});
};
export const useRotateSecretRotationV2 = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ type, rotationId }: TRotateSecretRotationV2DTO) => {
const { data } = await apiRequest.post<TSecretRotationV2Response>(
`/api/v2/secret-rotations/${type}/${rotationId}/rotate-secrets`
);
return data.secretRotation;
},
onSuccess: (_, { projectId, secretPath }) =>
queryClient.invalidateQueries({
queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
}),
onError: (_, { projectId, secretPath }) =>
queryClient.invalidateQueries({
queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
})
});
};
export const useDeleteSecretRotationV2 = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
type,
rotationId,
deleteSecrets,
revokeGeneratedCredentials
}: TDeleteSecretRotationV2DTO) => {
const { data } = await apiRequest.delete<TSecretRotationV2Response>(
`/api/v2/secret-rotations/${type}/${rotationId}`,
{ params: { deleteSecrets, revokeGeneratedCredentials } }
);
return data.secretRotation;
},
onSuccess: (_, { projectId, secretPath }) =>
queryClient.invalidateQueries({
queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
})
});
};
@@ -0,0 +1,73 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2/enums";
import {
TListSecretRotationV2Options,
TSecretRotationV2Option,
TViewSecretRotationGeneratedCredentialsResponse,
TViewSecretRotationV2GeneratedCredentialsDTO
} from "@app/hooks/api/secretRotationsV2/types";
export const secretRotationV2Keys = {
all: ["secret-rotations-v2"] as const,
options: () => [...secretRotationV2Keys.all, "options"] as const,
viewGeneratedCredentials: ({ type, rotationId }: TViewSecretRotationV2GeneratedCredentialsDTO) =>
[...secretRotationV2Keys.all, type, rotationId] as const
};
export const useSecretRotationV2Options = (
options?: Omit<
UseQueryOptions<
TSecretRotationV2Option[],
unknown,
TSecretRotationV2Option[],
ReturnType<typeof secretRotationV2Keys.options>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: secretRotationV2Keys.options(),
queryFn: async () => {
const { data } = await apiRequest.get<TListSecretRotationV2Options>(
"/api/v2/secret-rotations/options"
);
return data.secretRotationOptions;
},
...options
});
};
export const useSecretRotationV2Option = (type: SecretRotation) => {
const { data: rotationOptions, isPending } = useSecretRotationV2Options();
const rotationOption = rotationOptions?.find((option) => option.type === type);
return { rotationOption, isPending };
};
export const useViewSecretRotationV2GeneratedCredentials = (
{ rotationId, type }: TViewSecretRotationV2GeneratedCredentialsDTO,
options?: Omit<
UseQueryOptions<
TViewSecretRotationGeneratedCredentialsResponse,
unknown,
TViewSecretRotationGeneratedCredentialsResponse,
ReturnType<typeof secretRotationV2Keys.viewGeneratedCredentials>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: secretRotationV2Keys.viewGeneratedCredentials({ rotationId, type }),
queryFn: async () => {
const { data } = await apiRequest.get<TViewSecretRotationGeneratedCredentialsResponse>(
`/api/v2/secret-rotations/${type}/${rotationId}/generated-credentials`
);
return data;
},
...options
});
};
@@ -0,0 +1,73 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import {
TMsSqlCredentialsRotation,
TMsSqlCredentialsRotationGeneratedCredentialsResponse
} from "@app/hooks/api/secretRotationsV2/types/mssql-credentials-rotation";
import {
TPostgresCredentialsRotation,
TPostgresCredentialsRotationGeneratedCredentialsResponse
} from "@app/hooks/api/secretRotationsV2/types/postgres-credentials-rotation";
import { TSqlOptionTemplate } from "@app/hooks/api/secretRotationsV2/types/shared";
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { DiscriminativePick } from "@app/types";
export type TSecretRotationV2 = (TPostgresCredentialsRotation | TMsSqlCredentialsRotation) & {
secrets: (SecretV3RawSanitized | null)[];
};
export type TSecretRotationV2Option = {
name: string;
type: SecretRotation;
connection: AppConnection;
template: TSqlOptionTemplate;
};
export type TListSecretRotationV2Options = { secretRotationOptions: TSecretRotationV2Option[] };
export type TSecretRotationV2Response = { secretRotation: TSecretRotationV2 };
export type TViewSecretRotationGeneratedCredentialsResponse =
| TPostgresCredentialsRotationGeneratedCredentialsResponse
| TMsSqlCredentialsRotationGeneratedCredentialsResponse;
export type TCreateSecretRotationV2DTO = DiscriminativePick<
TSecretRotationV2,
| "name"
| "parameters"
| "secretsMapping"
| "description"
| "connectionId"
| "type"
| "isAutoRotationEnabled"
| "rotationInterval"
| "rotateAtUtc"
> & { environment: string; secretPath: string; projectId: string };
export type TUpdateSecretRotationV2DTO = Partial<
Omit<TCreateSecretRotationV2DTO, "type" | "secretPath" | "projectId">
> & {
type: SecretRotation;
rotationId: string;
// required for query invalidation
projectId: string;
secretPath: string;
};
export type TRotateSecretRotationV2DTO = {
rotationId: string;
type: SecretRotation;
// required for query invalidation
secretPath: string;
projectId: string;
};
export type TDeleteSecretRotationV2DTO = TRotateSecretRotationV2DTO & {
revokeGeneratedCredentials: boolean;
deleteSecrets: boolean;
};
export type TViewSecretRotationV2GeneratedCredentialsDTO = {
rotationId: string;
type: SecretRotation;
};
@@ -0,0 +1,17 @@
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import {
TSecretRotationV2Base,
TSecretRotationV2GeneratedCredentialsResponseBase,
TSqlCredentialsRotationGeneratedCredentials,
TSqlCredentialsRotationProperties
} from "@app/hooks/api/secretRotationsV2/types/shared";
export type TMsSqlCredentialsRotation = TSecretRotationV2Base & {
type: SecretRotation.MsSqlCredentials;
} & TSqlCredentialsRotationProperties;
export type TMsSqlCredentialsRotationGeneratedCredentialsResponse =
TSecretRotationV2GeneratedCredentialsResponseBase<
SecretRotation.MsSqlCredentials,
TSqlCredentialsRotationGeneratedCredentials
>;
@@ -0,0 +1,17 @@
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import {
TSecretRotationV2Base,
TSecretRotationV2GeneratedCredentialsResponseBase,
TSqlCredentialsRotationGeneratedCredentials,
TSqlCredentialsRotationProperties
} from "@app/hooks/api/secretRotationsV2/types/shared";
export type TPostgresCredentialsRotation = TSecretRotationV2Base & {
type: SecretRotation.PostgresCredentials;
} & TSqlCredentialsRotationProperties;
export type TPostgresCredentialsRotationGeneratedCredentialsResponse =
TSecretRotationV2GeneratedCredentialsResponseBase<
SecretRotation.PostgresCredentials,
TSqlCredentialsRotationGeneratedCredentials
>;
@@ -0,0 +1,2 @@
export * from "./secret-rotation-base";
export * from "./sql-credentials-rotation";
@@ -0,0 +1,53 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretRotationStatus } from "@app/hooks/api/secretRotationsV2";
export type TSecretRotationV2Base = {
id: string;
name: string;
description?: string | null;
folderId: string;
connectionId: string;
createdAt: string;
updatedAt: string;
rotationInterval: number;
rotateAtUtc: {
hours: number;
minutes: number;
};
projectId: string;
rotationStatus: SecretRotationStatus | null;
lastRotationJobId: string | null;
lastRotatedAt: string;
lastRotationAttemptedAt: string;
lastRotationMessage?: string | null;
connection: {
app: AppConnection;
id: string;
name: string;
};
environment: {
id: string;
name: string;
slug: string;
};
folder: {
id: string;
path: string;
};
} & (
| {
nextRotationAt: string;
isAutoRotationEnabled: true;
}
| {
nextRotationAt?: null;
isAutoRotationEnabled: false;
}
);
export type TSecretRotationV2GeneratedCredentialsResponseBase<U, T> = {
activeIndex: 0 | 1;
generatedCredentials: [T, T | undefined];
type: U;
rotationId: string;
};
@@ -0,0 +1,20 @@
export type TSqlCredentialsRotationProperties = {
parameters: {
username1: string;
username2: string;
};
secretsMapping: {
username: string;
password: string;
};
};
export type TSqlOptionTemplate = {
secretsMapping: TSqlCredentialsRotationProperties["secretsMapping"];
createUserStatement: string;
};
export type TSqlCredentialsRotationGeneratedCredentials = {
username: string;
password: string;
};
+3 -1
View File
@@ -85,7 +85,9 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
version: el.version,
skipMultilineEncoding: el.skipMultilineEncoding,
path: el.secretPath,
secretMetadata: el.secretMetadata
secretMetadata: el.secretMetadata,
isRotatedSecret: el.isRotatedSecret,
rotationId: el.rotationId
};
if (el.type === SecretType.Personal) {
+5 -1
View File
@@ -54,6 +54,8 @@ export type SecretV3RawSanitized = {
skipMultilineEncoding?: boolean;
secretMetadata?: { key: string; value: string }[];
isReminderEvent?: boolean;
isRotatedSecret?: boolean;
rotationId?: string;
};
export type SecretV3Raw = {
@@ -76,6 +78,8 @@ export type SecretV3Raw = {
tags?: WsTag[];
createdAt: string;
updatedAt: string;
isRotatedSecret?: boolean;
rotationId?: string;
};
export type SecretV3RawResponse = {
@@ -166,7 +170,7 @@ export type TUpdateSecretsV3DTO = {
skipMultilineEncoding?: boolean;
newSecretName?: string;
secretKey: string;
secretValue: string;
secretValue?: string;
secretComment?: string;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
@@ -72,6 +72,55 @@ export const useDynamicSecretOverview = (
return { dynamicSecretNames, isDynamicSecretPresentInEnv };
};
export const useSecretRotationOverview = (
secretRotations: DashboardProjectSecretsOverview["secretRotations"]
) => {
const secretRotationNames = useMemo(() => {
const names = new Set<string>();
secretRotations?.forEach((secretRotation) => {
names.add(secretRotation.name);
});
return [...names];
}, [secretRotations]);
const isSecretRotationPresentInEnv = useCallback(
(name: string, env: string) => {
return Boolean(
secretRotations?.find(
({ name: secretRotationName, environment }) =>
secretRotationName === name && environment.slug === env
)
);
},
[secretRotations]
);
const getSecretRotationByName = useCallback(
(env: string, name: string) => {
const secretRotation = secretRotations?.find(
(rotation) => rotation.environment.slug === env && rotation.name === name
);
return secretRotation;
},
[secretRotations]
);
const getSecretRotationStatusesByName = useCallback(
(name: string) =>
secretRotations
?.filter((rotation) => rotation.name === name)
.map((rotation) => rotation.rotationStatus),
[secretRotations]
);
return {
secretRotationNames,
isSecretRotationPresentInEnv,
getSecretRotationByName,
getSecretRotationStatusesByName
};
};
export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secrets"]) => {
const secKeys = useMemo(() => {
const keys = new Set<string>();
@@ -11,8 +11,12 @@ import {
MenuItem,
TBreadcrumbFormat
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api";
import { useSubscription, useWorkspace } from "@app/context";
import {
useGetAccessRequestsCount,
useGetSecretApprovalRequestCount,
useGetSecretRotations
} from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { ProjectSelect } from "./components/ProjectSelect";
@@ -42,6 +46,16 @@ export const ProjectLayout = () => {
options: { enabled: isSecretManager }
});
// we only show the secret rotations v1 tab if they have existing rotations
const { subscription } = useSubscription();
const { data: secretRotations } = useGetSecretRotations({
workspaceId,
options: {
enabled: isSecretManager && Boolean(subscription.secretRotation),
refetchOnMount: false
}
});
const pendingRequestsCount =
(secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
@@ -147,7 +161,7 @@ export const ProjectLayout = () => {
)}
</Link>
)}
{isSecretManager && (
{isSecretManager && Boolean(secretRotations?.length) && (
<Link
to={`/${ProjectType.SecretManager}/$projectId/secret-rotation` as const}
params={{
+7
View File
@@ -11,3 +11,10 @@ export const GenericResourceNameSchema = z
/^[a-zA-Z0-9\-_\s]+$/,
"Name can only contain alphanumeric characters, dashes, underscores, and spaces"
);
export const BaseSecretNameSchema = z.string().trim().min(1);
export const SecretNameSchema = BaseSecretNameSchema.refine(
(el) => !el.includes(" "),
"Secret name cannot contain spaces."
).refine((el) => !el.includes(":"), "Secret name cannot contain colon.");
@@ -91,7 +91,8 @@ export const AccessManagementPage = () => {
<p className="mb-2 mt-1 text-sm text-bunker-300">
We&apos;ve developed an improved privilege management system to better serve your
security needs. Upgrade to our new permission-based approach that allows you to
explicitly designate who can modify specific access levels, rather than relying on hierarchy comparisons.
explicitly designate who can modify specific access levels, rather than relying on
hierarchy comparisons.
</p>
<Button
colorSchema="primary"
@@ -16,6 +16,8 @@ import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
import { PostgresConnectionForm } from "./PostgresConnectionForm";
type FormProps = {
onComplete: (appConnection: TAppConnection) => void;
@@ -31,7 +33,10 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
const { name: appName } = APP_CONNECTION_MAP[app];
const onSubmit = async (
formData: DiscriminativePick<TAppConnection, "method" | "name" | "app" | "credentials">
formData: DiscriminativePick<
TAppConnection,
"method" | "name" | "app" | "credentials" | "isPlatformManagedCredentials"
>
) => {
try {
const connection = await createAppConnection.mutateAsync(formData);
@@ -65,6 +70,10 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <DatabricksConnectionForm onSubmit={onSubmit} />;
case AppConnection.Humanitec:
return <HumanitecConnectionForm onSubmit={onSubmit} />;
case AppConnection.Postgres:
return <PostgresConnectionForm onSubmit={onSubmit} />;
case AppConnection.MsSql:
return <MsSqlConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -75,7 +84,10 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
const { name: appName } = APP_CONNECTION_MAP[appConnection.app];
const onSubmit = async (
formData: DiscriminativePick<TAppConnection, "method" | "name" | "app" | "credentials">
formData: DiscriminativePick<
TAppConnection,
"method" | "name" | "app" | "credentials" | "isPlatformManagedCredentials"
>
) => {
try {
const connection = await updateAppConnection.mutateAsync({
@@ -112,6 +124,10 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <DatabricksConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Humanitec:
return <HumanitecConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Postgres:
return <PostgresConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.MsSql:
return <MsSqlConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
@@ -32,7 +32,8 @@ export const GenericAppConnectionsFields = () => {
isOptional
>
<TextArea
className="h-20 !resize-none"
className="!resize-none"
rows={1}
placeholder="Connection description..."
{...register("description")}
/>
@@ -30,7 +30,7 @@ const rootSchema = genericAppConnectionFieldsSchema.extend({
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(HumanitecConnectionMethod.API_TOKEN),
method: z.literal(HumanitecConnectionMethod.ApiToken),
credentials: z.object({
apiToken: z.string().trim().min(1, "Service API Token required")
})
@@ -46,7 +46,7 @@ export const HumanitecConnectionForm = ({ appConnection, onSubmit }: Props) => {
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Humanitec,
method: HumanitecConnectionMethod.API_TOKEN
method: HumanitecConnectionMethod.ApiToken
}
});
@@ -0,0 +1,146 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
MsSqlConnectionMethod,
TMsSqlConnection
} from "@app/hooks/api/appConnections/types/mssql-connection";
import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
import {
BaseSqlUsernameAndPasswordConnectionSchema,
PlatformManagedNoticeBanner,
SqlConnectionFields
} from "./shared";
type Props = {
appConnection?: TMsSqlConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.MsSql),
isPlatformManagedCredentials: z.boolean().optional()
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(MsSqlConnectionMethod.UsernameAndPassword),
credentials: BaseSqlUsernameAndPasswordConnectionSchema
})
]);
type FormData = z.infer<typeof formSchema>;
export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const [showConfirmation, setShowConfirmation] = useState(false);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.MsSql,
method: MsSqlConnectionMethod.UsernameAndPassword,
credentials: {
host: "",
port: 1433,
database: "default",
username: "",
password: "",
sslCertificate: ""
}
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
const confirmSubmit = (formData: FormData) => {
if (formData.isPlatformManagedCredentials) {
setShowConfirmation(true);
return;
}
onSubmit(formData);
};
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(confirmSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.Postgres].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(MsSqlConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<SqlConnectionFields isPlatformManagedCredentials={isPlatformManagedCredentials} />
{isPlatformManagedCredentials ? (
<PlatformManagedNoticeBanner />
) : (
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Database"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
)}
</form>
<PlatformManagedConfirmationModal
onConfirm={() => handleSubmit(onSubmit)()}
onOpenChange={setShowConfirmation}
isOpen={showConfirmation}
/>
</FormProvider>
);
};
@@ -0,0 +1,143 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { PostgresConnectionMethod, TPostgresConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
import {
BaseSqlUsernameAndPasswordConnectionSchema,
PlatformManagedNoticeBanner,
SqlConnectionFields
} from "./shared";
type Props = {
appConnection?: TPostgresConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.Postgres),
isPlatformManagedCredentials: z.boolean().optional()
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(PostgresConnectionMethod.UsernameAndPassword),
credentials: BaseSqlUsernameAndPasswordConnectionSchema
})
]);
type FormData = z.infer<typeof formSchema>;
export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const [showConfirmation, setShowConfirmation] = useState(false);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Postgres,
method: PostgresConnectionMethod.UsernameAndPassword,
credentials: {
host: "",
port: 5432,
database: "default",
username: "",
password: "",
sslCertificate: ""
}
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
const confirmSubmit = (formData: FormData) => {
if (formData.isPlatformManagedCredentials) {
setShowConfirmation(true);
return;
}
onSubmit(formData);
};
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(confirmSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.Postgres].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(PostgresConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<SqlConnectionFields isPlatformManagedCredentials={isPlatformManagedCredentials} />
{isPlatformManagedCredentials ? (
<PlatformManagedNoticeBanner />
) : (
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Database"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
)}
</form>
<PlatformManagedConfirmationModal
onConfirm={() => handleSubmit(onSubmit)()}
onOpenChange={setShowConfirmation}
isOpen={showConfirmation}
/>
</FormProvider>
);
};
@@ -0,0 +1,43 @@
import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onConfirm: () => void;
};
export const PlatformManagedConfirmationModal = ({ isOpen, onOpenChange, onConfirm }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent className="max-w-2xl" title="Platform Managed Credentials">
<NoticeBannerV2 title="Are you sure you want have Infisical manage the credentials of this connection?">
<p className="my-1 text-sm text-mineshaft-300">
Once created, Infisical will update the password of this connection.
</p>
<p className="text-sm text-mineshaft-300">
You will not be able to access the updated password.
</p>
</NoticeBannerV2>
<div className="mt-4 flex gap-2">
<ModalClose asChild>
<Button
onClick={onConfirm}
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
>
Grant Infisical Ownership
</Button>
</ModalClose>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,9 @@
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
export const PlatformManagedNoticeBanner = () => (
<NoticeBannerV2 title="Platform Managed Credentials">
<p className="text-sm text-bunker-300">
This App Connection&#39;s credentials are managed by Infisical and cannot be updated.
</p>
</NoticeBannerV2>
);
@@ -0,0 +1,155 @@
import { Controller, useFormContext } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormControl, Input, SecretInput, Switch, TextArea, Tooltip } from "@app/components/v2";
type Props = {
isPlatformManagedCredentials: boolean;
};
export const SqlConnectionFields = ({ isPlatformManagedCredentials }: Props) => {
const { control } = useFormContext();
return (
<>
<div className="flex items-start gap-2">
<Controller
name="credentials.host"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Host"
>
<Input {...field} isDisabled={isPlatformManagedCredentials} />
</FormControl>
)}
/>
<Controller
name="credentials.database"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Database Name"
>
<Input {...field} isDisabled={isPlatformManagedCredentials} />
</FormControl>
)}
/>
<Controller
name="credentials.port"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
className="w-28"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Port"
>
<Input type="number" {...field} isDisabled={isPlatformManagedCredentials} />
</FormControl>
)}
/>
</div>
<div className="flex items-start gap-2">
<Controller
name="credentials.username"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Username"
className="flex-1"
>
<Input {...field} isDisabled={isPlatformManagedCredentials} />
</FormControl>
)}
/>
<Controller
name="credentials.password"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Password"
className="flex-1"
>
<SecretInput
containerClassName="text-gray-400 w-full 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)}
isDisabled={isPlatformManagedCredentials}
/>
</FormControl>
)}
/>
</div>
<Controller
name="credentials.sslCertificate"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="SSL Certificate"
isOptional
>
<TextArea
className="!resize-none"
rows={1}
{...field}
isDisabled={isPlatformManagedCredentials}
/>
</FormControl>
)}
/>
{!isPlatformManagedCredentials && (
<Controller
name="isPlatformManagedCredentials"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="platform-managed"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
isDisabled={isPlatformManagedCredentials}
>
<p className="w-[13.6rem]">
Platform Managed Credentials
<Tooltip
className="max-w-md"
content={
<p>
If enabled, Infisical will manage the credentials of this App Connection by
updating the password on creation.
</p>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
)}
</>
);
};
@@ -0,0 +1,3 @@
export * from "./PlatformManagedNoticeBanner";
export * from "./sql-connection-schemas";
export * from "./SqlConnectionFields";
@@ -0,0 +1,10 @@
import { z } from "zod";
export const BaseSqlUsernameAndPasswordConnectionSchema = z.object({
host: z.string().trim().min(1, "Host required"),
port: z.coerce.number().default(5432),
database: z.string().trim().min(1, "Database required").default("default"),
username: z.string().trim().min(1, "Username required"),
password: z.string().trim().min(1, "Password required"),
sslCertificate: z.string().trim().optional()
});
@@ -6,6 +6,7 @@ import {
faEdit,
faEllipsisV,
faInfoCircle,
faServer,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -14,6 +15,7 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -42,7 +44,7 @@ export const AppConnectionRow = ({
onEditCredentials,
onEditDetails
}: Props) => {
const { id, name, method, app, description } = appConnection;
const { id, name, method, app, description, isPlatformManagedCredentials } = appConnection;
const [isIdCopied, setIsIdCopied] = useToggle(false);
@@ -100,70 +102,82 @@ export const AppConnectionRow = ({
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
>
Copy Connection ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => onEditDetails(appConnection)}
>
Edit Details
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faAsterisk} />}
onClick={() => onEditCredentials(appConnection)}
>
Edit Credentials
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Delete}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(appConnection)}
>
Delete Connection
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
<div className="flex items-center justify-end gap-2">
{isPlatformManagedCredentials && (
<Tooltip side="left" content="This connection's credentials are managed by Infisical.">
<div>
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap">
<FontAwesomeIcon icon={faServer} />
<span>Platform Managed Credentials</span>
</Badge>
</div>
</Tooltip>
)}
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
>
Copy Connection ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => onEditDetails(appConnection)}
>
Edit Details
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faAsterisk} />}
onClick={() => onEditCredentials(appConnection)}
>
{isPlatformManagedCredentials ? "View" : "Edit"} Credentials
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Delete}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(appConnection)}
>
Delete Connection
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</div>
</Td>
</Tr>
);
@@ -28,7 +28,8 @@ type Props = {
type:
| ProjectPermissionSub.DynamicSecrets
| ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports;
| ProjectPermissionSub.SecretImports
| ProjectPermissionSub.SecretRotation;
};
export const GeneralPermissionConditions = ({ position = 0, isDisabled, type }: Props) => {
@@ -17,6 +17,7 @@ import {
ProjectPermissionKmipActions,
ProjectPermissionMemberActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions,
ProjectPermissionSecretSyncActions,
TPermissionCondition,
TPermissionConditionOperators
@@ -66,6 +67,15 @@ const SecretSyncPolicyActionSchema = z.object({
[ProjectPermissionSecretSyncActions.RemoveSecrets]: z.boolean().optional()
});
const SecretRotationPolicyActionSchema = z.object({
[ProjectPermissionSecretRotationActions.Read]: z.boolean().optional(),
[ProjectPermissionSecretRotationActions.ReadGeneratedCredentials]: z.boolean().optional(),
[ProjectPermissionSecretRotationActions.Create]: z.boolean().optional(),
[ProjectPermissionSecretRotationActions.Edit]: z.boolean().optional(),
[ProjectPermissionSecretRotationActions.Delete]: z.boolean().optional(),
[ProjectPermissionSecretRotationActions.RotateSecrets]: z.boolean().optional()
});
const KmipPolicyActionSchema = z.object({
[ProjectPermissionKmipActions.ReadClients]: z.boolean().optional(),
[ProjectPermissionKmipActions.CreateClients]: z.boolean().optional(),
@@ -209,7 +219,12 @@ export const projectRoleFormSchema = z.object({
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRotation]: SecretRotationPolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.array().default([]),
@@ -226,6 +241,7 @@ type TConditionalFields =
| ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports
| ProjectPermissionSub.DynamicSecrets
| ProjectPermissionSub.SecretRotation
| ProjectPermissionSub.Identity;
export const isConditionalSubjects = (
@@ -235,6 +251,7 @@ export const isConditionalSubjects = (
subject === ProjectPermissionSub.DynamicSecrets ||
subject === ProjectPermissionSub.SecretImports ||
subject === ProjectPermissionSub.SecretFolders ||
subject === ProjectPermissionSub.SecretRotation ||
subject === ProjectPermissionSub.Identity;
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
@@ -298,6 +315,30 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (isConditionalSubjects(subject)) {
if (!formVal[subject]) formVal[subject] = [];
if (subject === ProjectPermissionSub.SecretRotation) {
const canRead = action.includes(ProjectPermissionSecretRotationActions.Read);
const canReadCredentials = action.includes(
ProjectPermissionSecretRotationActions.ReadGeneratedCredentials
);
const canEdit = action.includes(ProjectPermissionSecretRotationActions.Edit);
const canDelete = action.includes(ProjectPermissionSecretRotationActions.Delete);
const canCreate = action.includes(ProjectPermissionSecretRotationActions.Create);
const canRotate = action.includes(ProjectPermissionSecretRotationActions.RotateSecrets);
// from above statement we are sure it won't be undefined
formVal[subject]!.push({
[ProjectPermissionSecretRotationActions.Read]: canRead,
[ProjectPermissionSecretRotationActions.ReadGeneratedCredentials]: canReadCredentials,
[ProjectPermissionSecretRotationActions.Create]: canCreate,
[ProjectPermissionSecretRotationActions.Edit]: canEdit,
[ProjectPermissionSecretRotationActions.Delete]: canDelete,
[ProjectPermissionSecretRotationActions.RotateSecrets]: canRotate,
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
});
return;
}
if (subject === ProjectPermissionSub.DynamicSecrets) {
const canRead = action.includes(ProjectPermissionDynamicSecretActions.ReadRootCredential);
const canEdit = action.includes(ProjectPermissionDynamicSecretActions.EditRootCredential);
@@ -890,10 +931,15 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
[ProjectPermissionSub.SecretRotation]: {
title: "Secret Rotation",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
{ label: "Read Config", value: ProjectPermissionSecretRotationActions.Read },
{
label: "Read Generated Credentials",
value: ProjectPermissionSecretRotationActions.ReadGeneratedCredentials
},
{ label: "Create Config", value: ProjectPermissionSecretRotationActions.Create },
{ label: "Modify Config", value: ProjectPermissionSecretRotationActions.Edit },
{ label: "Remove Config", value: ProjectPermissionSecretRotationActions.Delete },
{ label: "Rotate Secrets", value: ProjectPermissionSecretRotationActions.RotateSecrets }
]
},
[ProjectPermissionSub.SecretRollback]: {
@@ -14,7 +14,8 @@ import {
faFolderPlus,
faKey,
faList,
faPlus
faPlus,
faRotate
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router";
@@ -23,6 +24,11 @@ import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { CreateSecretRotationV2Modal } from "@app/components/secret-rotations-v2";
import { DeleteSecretRotationV2Modal } from "@app/components/secret-rotations-v2/DeleteSecretRotationV2Modal";
import { EditSecretRotationV2Modal } from "@app/components/secret-rotations-v2/EditSecretRotationV2Modal";
import { RotateSecretRotationV2Modal } from "@app/components/secret-rotations-v2/RotateSecretRotationV2Modal";
import { ViewSecretRotationV2GeneratedCredentialsModal } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials";
import {
Button,
Checkbox,
@@ -57,6 +63,7 @@ import {
useSubscription,
useWorkspace
} from "@app/context";
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import {
useCreateFolder,
@@ -71,9 +78,16 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types";
import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types";
import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
import {
useDynamicSecretOverview,
useFolderOverview,
useSecretOverview,
useSecretRotationOverview
} from "@app/hooks/utils";
import { SecretOverviewSecretRotationRow } from "@app/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow";
import { CreateDynamicSecretForm } from "../SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm";
import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm";
@@ -99,7 +113,8 @@ enum RowType {
Folder = "folder",
DynamicSecret = "dynamic",
Secret = "secret",
Import = "import"
Import = "import",
SecretRotation = "rotation"
}
type Filter = {
@@ -110,7 +125,8 @@ const DEFAULT_FILTER_STATE = {
[RowType.Folder]: true,
[RowType.DynamicSecret]: true,
[RowType.Secret]: true,
[RowType.Import]: true
[RowType.Import]: true,
[RowType.SecretRotation]: true
};
export const OverviewPage = () => {
@@ -195,6 +211,15 @@ export const OverviewPage = () => {
})
)
);
const userAvailableSecretRotationEnvs = userAvailableEnvs.filter((env) =>
permission.can(
ProjectPermissionSecretRotationActions.Create,
subject(ProjectPermissionSub.SecretRotation, {
environment: env.slug,
secretPath
})
)
);
const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs);
@@ -224,6 +249,7 @@ export const OverviewPage = () => {
includeDynamicSecrets: filter.dynamic,
includeSecrets: filter.secret,
includeImports: filter.import,
includeSecretRotations: filter.rotation,
search: debouncedSearchFilter,
limit,
offset
@@ -235,15 +261,18 @@ export const OverviewPage = () => {
secrets,
folders,
dynamicSecrets,
secretRotations,
totalFolderCount,
totalSecretCount,
totalDynamicSecretCount,
totalSecretRotationCount,
totalImportCount,
totalCount = 0,
totalUniqueFoldersInPage,
totalUniqueSecretsInPage,
totalUniqueSecretImportsInPage,
totalUniqueDynamicSecretsInPage
totalUniqueDynamicSecretsInPage,
totalUniqueSecretRotationsInPage
} = overview ?? {};
const secretImportsShaped = secretImports
@@ -273,6 +302,13 @@ export const OverviewPage = () => {
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
useDynamicSecretOverview(dynamicSecrets);
const {
secretRotationNames,
isSecretRotationPresentInEnv,
getSecretRotationByName,
getSecretRotationStatusesByName
} = useSecretRotationOverview(secretRotations);
const { secKeys, getEnvSecretKeyCount } = useSecretOverview(
secrets?.concat(secretImportsShaped) || []
);
@@ -301,6 +337,11 @@ export const OverviewPage = () => {
"misc",
"updateFolder",
"addDynamicSecret",
"addSecretRotation",
"editSecretRotation",
"rotateSecretRotation",
"viewSecretRotationGeneratedCredentials",
"deleteSecretRotation",
"upgradePlan"
] as const);
@@ -841,6 +882,21 @@ export const OverviewPage = () => {
<span>Dynamic Secrets</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.SecretRotation);
}}
icon={
filter[RowType.SecretRotation] && <FontAwesomeIcon icon={faCheckCircle} />
}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faRotate} className="text-mineshaft-400" />
<span>Secret Rotations</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
@@ -952,6 +1008,29 @@ export const OverviewPage = () => {
Add Dynamic Secret
</Button>
</Tooltip>
<Tooltip
content={
userAvailableSecretRotationEnvs.length === 0 ? "Access restricted" : ""
}
>
<Button
leftIcon={<FontAwesomeIcon icon={faRotate} className="pr-2" />}
onClick={() => {
if (subscription?.secretRotation) {
handlePopUpOpen("addSecretRotation");
handlePopUpClose("misc");
return;
}
handlePopUpOpen("upgradePlan");
}}
isDisabled={userAvailableSecretRotationEnvs.length === 0}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add Secret Rotation
</Button>
</Tooltip>
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -1141,6 +1220,29 @@ export const OverviewPage = () => {
key={`overview-${dynamicSecretName}-${index + 1}`}
/>
))}
{secretRotationNames.map((secretRotationName, index) => (
<SecretOverviewSecretRotationRow
secretRotationName={secretRotationName}
isSecretRotationInEnv={isSecretRotationPresentInEnv}
environments={visibleEnvs}
getSecretRotationByName={getSecretRotationByName}
getSecretRotationStatusesByName={getSecretRotationStatusesByName}
key={`overview-${secretRotationName}-${index + 1}`}
scrollOffset={scrollOffset}
onEdit={(secretRotation) =>
handlePopUpOpen("editSecretRotation", secretRotation)
}
onRotate={(secretRotation) =>
handlePopUpOpen("rotateSecretRotation", secretRotation)
}
onViewGeneratedCredentials={(secretRotation) =>
handlePopUpOpen("viewSecretRotationGeneratedCredentials", secretRotation)
}
onDelete={(secretRotation) =>
handlePopUpOpen("deleteSecretRotation", secretRotation)
}
/>
))}
{secKeys.map((key, index) => (
<SecretOverviewTableRow
isSelected={Boolean(selectedEntries.secret[key])}
@@ -1165,7 +1267,8 @@ export const OverviewPage = () => {
(totalUniqueFoldersInPage || 0) -
(totalUniqueDynamicSecretsInPage || 0) -
(totalUniqueSecretsInPage || 0) -
(totalUniqueSecretImportsInPage || 0),
(totalUniqueSecretImportsInPage || 0) -
(totalUniqueSecretRotationsInPage || 0),
0
)}
/>
@@ -1206,6 +1309,7 @@ export const OverviewPage = () => {
secretCount={totalSecretCount}
folderCount={totalFolderCount}
importCount={totalImportCount}
secretRotationCount={totalSecretRotationCount}
/>
}
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
@@ -1277,6 +1381,34 @@ export const OverviewPage = () => {
}
/>
)}
<CreateSecretRotationV2Modal
secretPath={secretPath}
environments={userAvailableSecretRotationEnvs}
isOpen={popUp.addSecretRotation.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addSecretRotation", isOpen)}
/>
<EditSecretRotationV2Modal
isOpen={popUp.editSecretRotation.isOpen}
secretRotation={popUp.editSecretRotation.data as TSecretRotationV2}
onOpenChange={(isOpen) => handlePopUpToggle("editSecretRotation", isOpen)}
/>
<RotateSecretRotationV2Modal
isOpen={popUp.rotateSecretRotation.isOpen}
secretRotation={popUp.rotateSecretRotation.data as TSecretRotationV2}
onOpenChange={(isOpen) => handlePopUpToggle("rotateSecretRotation", isOpen)}
/>
<ViewSecretRotationV2GeneratedCredentialsModal
isOpen={popUp.viewSecretRotationGeneratedCredentials.isOpen}
secretRotation={popUp.viewSecretRotationGeneratedCredentials.data as TSecretRotationV2}
onOpenChange={(isOpen) =>
handlePopUpToggle("viewSecretRotationGeneratedCredentials", isOpen)
}
/>
<DeleteSecretRotationV2Modal
isOpen={popUp.deleteSecretRotation.isOpen}
secretRotation={popUp.deleteSecretRotation.data as TSecretRotationV2}
onOpenChange={(isOpen) => handlePopUpToggle("deleteSecretRotation", isOpen)}
/>
</div>
);
};
@@ -0,0 +1,318 @@
import { subject } from "@casl/ability";
import {
faAsterisk,
faCheck,
faClose,
faEdit,
faEye,
faEyeSlash,
faInfoCircle,
faRotate,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import { SecretRotationV2StatusBadge } from "@app/components/secret-rotations-v2/SecretRotationV2StatusBadge";
import { Badge, IconButton, TableContainer, Tag, Td, Tooltip, Tr } from "@app/components/v2";
import { Blur } from "@app/components/v2/Blur";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import {
ProjectPermissionSecretRotationActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import { useToggle } from "@app/hooks";
import { SecretRotationStatus, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { getExpandedRowStyle } from "@app/pages/secret-manager/OverviewPage/components/utils";
type Props = {
secretRotationName: string;
environments: { name: string; slug: string }[];
isSecretRotationInEnv: (name: string, env: string) => boolean;
getSecretRotationByName: (slug: string, name: string) => TSecretRotationV2 | undefined;
getSecretRotationStatusesByName: (name: string) => (SecretRotationStatus | null)[] | undefined;
scrollOffset: number;
onEdit: (secretRotation: TSecretRotationV2) => void;
onRotate: (secretRotation: TSecretRotationV2) => void;
onViewGeneratedCredentials: (secretRotation: TSecretRotationV2) => void;
onDelete: (secretRotation: TSecretRotationV2) => void;
};
export const SecretOverviewSecretRotationRow = ({
secretRotationName,
environments = [],
isSecretRotationInEnv,
scrollOffset,
getSecretRotationByName,
getSecretRotationStatusesByName,
onEdit,
onRotate,
onViewGeneratedCredentials,
onDelete
}: Props) => {
const [isExpanded, setIsExpanded] = useToggle(false);
const [isSecretVisible, setIsSecretVisible] = useToggle();
const totalCols = environments.length + 1; // secret key row
const statuses = getSecretRotationStatusesByName(secretRotationName);
return (
<>
<Tr isHoverable isSelectable onClick={setIsExpanded.toggle} className="group">
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
<div className="flex w-full items-center space-x-5 border-r border-mineshaft-600 py-2.5 pl-5 pr-2">
<div className="text-mineshaft-400">
<FontAwesomeIcon icon={faRotate} />
</div>
<div className="flex-1">{secretRotationName}</div>
{statuses?.some((status) => status === SecretRotationStatus.Failed) && (
<Tooltip className="max-w-sm" content="One or more secrets failed to rotate.">
<div>
<Badge
variant="danger"
className="flex h-5 w-min items-center gap-1 whitespace-nowrap"
>
<FontAwesomeIcon icon={faXmark} />
<span>Rotation Failed</span>
</Badge>
</div>
</Tooltip>
)}
</div>
</Td>
{environments.map(({ slug }, i) => {
const isPresent = isSecretRotationInEnv(secretRotationName, slug);
return (
<Td
key={`sec-overview-${slug}-${i + 1}-folder`}
className={twMerge(
"border-r border-mineshaft-600 py-3 group-hover:bg-mineshaft-700",
isPresent ? "text-green-600" : "text-red-600"
)}
>
<div className="flex justify-center">
<FontAwesomeIcon
// eslint-disable-next-line no-nested-ternary
icon={isPresent ? faCheck : faXmark}
/>
</div>
</Td>
);
})}
</Tr>
{isExpanded &&
environments.map(({ name: envName, slug }) => {
const secretRotation = getSecretRotationByName(slug, secretRotationName);
if (!secretRotation) return null;
const { type, secrets, environment, folder, description } = secretRotation;
const { name: rotationType, image } = SECRET_ROTATION_MAP[type];
return (
<Tr key={`secret-rotation-${slug}-${secretRotationName}`}>
<Td
colSpan={totalCols}
className={`bg-bunker-600 px-0 py-0 ${isExpanded && "border-b-2 border-mineshaft-500"}`}
>
<div style={getExpandedRowStyle(scrollOffset)} className="ml-2 p-2">
<TableContainer>
<table className="secret-table">
<thead className="!border-b">
<tr className="h-10">
<th
style={{ padding: "0.5rem 1rem" }}
className="min-table-row min-w-[30vw] !border-r-0"
>
<div className="flex w-full flex-wrap items-center">
<span>{envName}</span>
<Tag className="mx-2.5 flex items-center gap-1 px-1.5 py-0 text-xs normal-case">
<img
src={`/images/integrations/${image}`}
style={{
height: "11px",
width: "11px"
}}
alt={`${rotationType} logo`}
/>
{rotationType}
</Tag>
{description && (
<Tooltip content={description}>
<FontAwesomeIcon
icon={faInfoCircle}
className="text-mineshaft-400"
/>
</Tooltip>
)}
</div>
</th>
<div className="absolute right-1 top-0.5 ml-auto mr-1 mt-1 w-min">
<div className="flex items-center gap-2">
<SecretRotationV2StatusBadge secretRotation={secretRotation} />
<Tooltip content={isSecretVisible ? "Hide Values" : "Reveal Values"}>
<IconButton
ariaLabel={isSecretVisible ? "Hide Values" : "Reveal Values"}
variant="plain"
size="md"
onClick={() => setIsSecretVisible.toggle()}
>
<FontAwesomeIcon icon={isSecretVisible ? faEyeSlash : faEye} />
</IconButton>
</Tooltip>
<ProjectPermissionCan
I={ProjectPermissionSecretRotationActions.ReadGeneratedCredentials}
a={subject(ProjectPermissionSub.SecretRotation, {
environment: environment.slug,
secretPath: folder.path
})}
renderTooltip
allowedLabel="View Generated Credentials"
>
{(isAllowed) => (
<IconButton
ariaLabel="View generated credentials"
variant="plain"
size="sm"
isDisabled={!isAllowed}
onClick={() => onViewGeneratedCredentials(secretRotation)}
>
<FontAwesomeIcon icon={faAsterisk} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretRotationActions.RotateSecrets}
a={subject(ProjectPermissionSub.SecretRotation, {
environment: environment.slug,
secretPath: folder.path
})}
renderTooltip
allowedLabel="Rotate Secrets"
>
{(isAllowed) => (
<IconButton
ariaLabel="Rotate secrets"
variant="plain"
size="sm"
isDisabled={!isAllowed}
onClick={() => onRotate(secretRotation)}
>
<FontAwesomeIcon icon={faRotate} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretRotationActions.Edit}
a={subject(ProjectPermissionSub.SecretRotation, {
environment: environment.slug,
secretPath: folder.path
})}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="Edit rotation"
variant="plain"
size="md"
isDisabled={!isAllowed}
onClick={() => onEdit(secretRotation)}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretRotationActions.Delete}
a={subject(ProjectPermissionSub.SecretRotation, {
environment: environment.slug,
secretPath: folder.path
})}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="Delete rotation"
variant="plain"
colorSchema="danger"
size="md"
onClick={() => onDelete(secretRotation)}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div>
</tr>
</thead>
<tbody className="border-t-2 border-mineshaft-600">
{secrets.map((secret, index) => {
return (
<Tooltip
className="max-w-sm"
content={
secret
? undefined
: "You do not have permission to view this secret."
}
>
<tr
// eslint-disable-next-line react/no-array-index-key
key={`rotation-secret-${secretRotation.id}-${index}`}
className="!last:border-b-0 h-full hover:bg-mineshaft-700"
>
<td
className="flex h-full items-center"
style={{ padding: "0.5rem 1rem" }}
>
<span className={twMerge(!secret && "blur")}>
{secret?.key ?? "********"}
</span>
</td>
<td
className="col-span-2 h-full w-full"
style={{ padding: "0.5rem 1rem" }}
>
{/* eslint-disable-next-line no-nested-ternary */}
{!secret ? (
<div className="h-full pl-4 blur">********</div>
) : secret.secretValueHidden ? (
<Blur
className="py-0"
tooltipText="You do not have permission to read the value of this secret."
/>
) : (
<InfisicalSecretInput
isReadOnly
value={secret.value}
isVisible={isSecretVisible}
secretPath={secretRotation.folder.path}
environment={secretRotation.environment.slug}
onChange={() => {}}
/>
)}
</td>
</tr>
</Tooltip>
);
})}
</tbody>
</table>
</TableContainer>
</div>
</Td>
</Tr>
);
})}
</>
);
};

Some files were not shown because too many files have changed in this diff Show More