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";