diff --git a/frontend/public/images/secretRotation/mysql.png b/frontend/public/images/secretRotation/mysql.png new file mode 100644 index 000000000..d92befdbc Binary files /dev/null and b/frontend/public/images/secretRotation/mysql.png differ diff --git a/frontend/public/images/secretRotation/postgres.png b/frontend/public/images/secretRotation/postgres.png new file mode 100644 index 000000000..b7152860d Binary files /dev/null and b/frontend/public/images/secretRotation/postgres.png differ diff --git a/frontend/public/images/secretRotation/sendgrid.png b/frontend/public/images/secretRotation/sendgrid.png new file mode 100644 index 000000000..3d2c9a92d Binary files /dev/null and b/frontend/public/images/secretRotation/sendgrid.png differ diff --git a/frontend/src/components/v2/Stepper/Stepper.tsx b/frontend/src/components/v2/Stepper/Stepper.tsx new file mode 100644 index 000000000..8eabddaf6 --- /dev/null +++ b/frontend/src/components/v2/Stepper/Stepper.tsx @@ -0,0 +1,78 @@ +import { Children, cloneElement, ReactElement, ReactNode } from "react"; +import { faCheck } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +export type StepperProps = { + activeStep: number; + children: ReactNode; + direction: "vertical" | "horizontal"; + className?: string; +}; + +export const Stepper = ({ activeStep, children, direction, className }: StepperProps) => { + return ( +
+ {Children.map(children as ReactNode, (child: ReactNode, index) => { + const isCompleted = activeStep > index; + const isActive = index === activeStep; + const isNotLast = index + 1 !== (children as Array).length; + return ( +
+
+
+ {isCompleted ? : index + 1} +
+ {cloneElement(child as ReactElement, { + direction, + activeStep, + isCompleted, + isActive + })} +
+ {isNotLast && ( +
+ )} +
+ ); + })} +
+ ); +}; + +export type StepProps = { + title: string; + description?: ReactNode; + // isActive?: boolean; + // isCompleted?: boolean; + // activeStep?: number; + // direction?: "vertical" | "horizontal"; +}; + +export const Step = ({ title, description }: StepProps) => { + return ( +
+
{title}
+ {description &&
{description}
} +
+ ); +}; diff --git a/frontend/src/components/v2/Stepper/index.tsx b/frontend/src/components/v2/Stepper/index.tsx new file mode 100644 index 000000000..b90544cdd --- /dev/null +++ b/frontend/src/components/v2/Stepper/index.tsx @@ -0,0 +1,2 @@ +export type { StepperProps,StepProps } from "./Stepper"; +export { Step,Stepper } from "./Stepper"; diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 31e2b4e57..26af93f37 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -22,6 +22,7 @@ export * from "./SecretInput"; export * from "./Select"; export * from "./Skeleton"; export * from "./Spinner"; +export * from "./Stepper"; export * from "./Switch"; export * from "./Table"; export * from "./Tabs"; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index ae4b49d63..5ae91e756 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -21,7 +21,8 @@ export enum ProjectPermissionSub { Workspace = "workspace", Secrets = "secrets", SecretRollback = "secret-rollback", - SecretApproval = "secret-approval" + SecretApproval = "secret-approval", + SecretRotation = "secret-rotation" } type SubjectFields = { @@ -45,6 +46,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] + | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 008f1e2fb..c707fe33f 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -12,6 +12,7 @@ export * from "./secretApproval"; export * from "./secretApprovalRequest"; export * from "./secretFolders"; export * from "./secretImports"; +export * from "./secretRotation"; export * from "./secrets"; export * from "./secretSnapshots"; export * from "./serviceAccounts"; diff --git a/frontend/src/hooks/api/secretRotation/index.ts b/frontend/src/hooks/api/secretRotation/index.ts new file mode 100644 index 000000000..906c8b498 --- /dev/null +++ b/frontend/src/hooks/api/secretRotation/index.ts @@ -0,0 +1,6 @@ +export { + useCreateSecretRotation, + useDeleteSecretRotation, + useRestartSecretRotation +} from "./mutation"; +export { useGetSecretRotationProviders, useGetSecretRotations } from "./queries"; diff --git a/frontend/src/hooks/api/secretRotation/mutation.tsx b/frontend/src/hooks/api/secretRotation/mutation.tsx new file mode 100644 index 000000000..cda4f8074 --- /dev/null +++ b/frontend/src/hooks/api/secretRotation/mutation.tsx @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { secretRotationKeys } from "./queries"; +import { + TCreateSecretRotationDTO, + TDeleteSecretRotationDTO, + TRestartSecretRotationDTO +} from "./types"; + +export const useCreateSecretRotation = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateSecretRotationDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post("/api/v1/secret-rotations", dto); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretRotationKeys.list({ workspaceId })); + } + }); +}; + +export const useDeleteSecretRotation = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSecretRotationDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete(`/api/v1/secret-rotations/${dto.id}`); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretRotationKeys.list({ workspaceId })); + } + }); +}; + +export const useRestartSecretRotation = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TRestartSecretRotationDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post("/api/v1/secret-rotations/restart", { id: dto.id }); + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(secretRotationKeys.list({ workspaceId })); + } + }); +}; diff --git a/frontend/src/hooks/api/secretRotation/queries.tsx b/frontend/src/hooks/api/secretRotation/queries.tsx new file mode 100644 index 000000000..a3c8e270e --- /dev/null +++ b/frontend/src/hooks/api/secretRotation/queries.tsx @@ -0,0 +1,110 @@ +import { useCallback } from "react"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { + decryptAssymmetric, + decryptSymmetric +} from "@app/components/utilities/cryptography/crypto"; +import { apiRequest } from "@app/config/request"; + +import { + TGetSecretRotationList, + TGetSecretRotationProviders, + TSecretRotation, + TSecretRotationProviderList +} from "./types"; + +export const secretRotationKeys = { + listProviders: ({ workspaceId }: TGetSecretRotationProviders) => [ + { workspaceId }, + "secret-rotation-providers" + ], + list: ({ workspaceId }: Omit) => + [{ workspaceId }, "secret-rotations"] as const +}; + +const fetchSecretRotationProviders = async ({ workspaceId }: TGetSecretRotationProviders) => { + const { data } = await apiRequest.get( + `/api/v1/secret-rotation-providers/${workspaceId}` + ); + return data; +}; + +export const useGetSecretRotationProviders = ({ + workspaceId, + options = {} +}: TGetSecretRotationProviders & { + options?: Omit< + UseQueryOptions< + TSecretRotationProviderList, + unknown, + TSecretRotationProviderList, + ReturnType + >, + "queryKey" | "queryFn" + >; +}) => + useQuery({ + ...options, + queryKey: secretRotationKeys.listProviders({ workspaceId }), + enabled: Boolean(workspaceId) && (options?.enabled ?? true), + queryFn: async () => fetchSecretRotationProviders({ workspaceId }) + }); + +const fetchSecretRotations = async ({ + workspaceId +}: Omit) => { + const { data } = await apiRequest.get<{ secretRotations: TSecretRotation[] }>( + "/api/v1/secret-rotations", + { params: { workspaceId } } + ); + return data.secretRotations; +}; + +export const useGetSecretRotations = ({ + workspaceId, + decryptFileKey, + options = {} +}: TGetSecretRotationList & { + options?: Omit< + UseQueryOptions< + TSecretRotation[], + unknown, + TSecretRotation<{ key: string }>[], + ReturnType + >, + "queryKey" | "queryFn" + >; +}) => + useQuery({ + ...options, + queryKey: secretRotationKeys.list({ workspaceId }), + enabled: Boolean(workspaceId) && (options?.enabled ?? true), + queryFn: async () => fetchSecretRotations({ workspaceId }), + select: useCallback( + (data: TSecretRotation[]) => { + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; + const decryptKey = decryptAssymmetric({ + ciphertext: decryptFileKey.encryptedKey, + nonce: decryptFileKey.nonce, + publicKey: decryptFileKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + return data.map((el) => ({ + ...el, + outputs: el.outputs.map(({ key, secret }) => ({ + key, + secret: { + key: decryptSymmetric({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key: decryptKey + }) + } + })) + })); + }, + [decryptFileKey] + ) + }); diff --git a/frontend/src/hooks/api/secretRotation/types.ts b/frontend/src/hooks/api/secretRotation/types.ts new file mode 100644 index 000000000..76d3b5174 --- /dev/null +++ b/frontend/src/hooks/api/secretRotation/types.ts @@ -0,0 +1,133 @@ +import { UserWsKeyPair } from "../keys/types"; +import { EncryptedSecret } from "../secrets/types"; + +export enum TProviderFunctionTypes { + HTTP = "http", + DB = "database" +} + +export enum TDbProviderClients { + // postgres, cockroack db, amazon red shift + Pg = "pg", + // mysql and maria db + Sql = "sql" +} + +export enum TAssignOp { + Direct = "direct", + JmesPath = "jmesopath" +} + +export type TJmesPathAssignOp = { + assign: TAssignOp.JmesPath; + path: string; +}; + +export type TDirectAssignOp = { + assign: TAssignOp.Direct; + value: string; +}; + +export type TAssignFunction = TJmesPathAssignOp | TDirectAssignOp; + +export type THttpProviderFunction = { + type: TProviderFunctionTypes.HTTP; + url: string; + method: string; + header?: Record; + query?: Record; + body?: Record; + setter?: Record; + pre?: Record; +}; + +export type TDbProviderFunction = { + type: TProviderFunctionTypes.DB; + client: TDbProviderClients; + username: string; + password: string; + host: string; + database: string; + port: string; + query: string; + setter?: Record; + pre?: Record; +}; + +export type TProviderFunction = THttpProviderFunction | TDbProviderFunction; + +export type TProviderTemplate = { + inputs: { + properties: Record; + type: "object"; + required: string[]; + }; + outputs: Record; + functions: { + set: TProviderFunction; + remove?: TProviderFunction; + test: TProviderFunction; + }; +}; + +export type TSecretRotation = { + _id: string; + interval: number; + provider: string; + customProvider: string; + workspace: string; + environment: string; + secretPath: string; + outputs: Array<{ + key: string; + secret: T; + }>; + status?: "success" | "failed"; + lastRotatedAt?: string; + statusMessage?: string; + algorithm: string; + keyEncoding: string; +}; + +export type TSecretRotationProvider = { + name: string; + image: string; + title: string; + description: string; + template: TProviderTemplate; +}; + +export type TSecretRotationProviderList = { + custom: TSecretRotationProvider[]; + providers: TSecretRotationProvider[]; +}; + +export type TGetSecretRotationProviders = { + workspaceId: string; +}; + +export type TGetSecretRotationList = { + workspaceId: string; + decryptFileKey: UserWsKeyPair; +}; + +export type TCreateSecretRotationDTO = { + workspaceId: string; + secretPath: string; + environment: string; + interval: number; + provider: string; + customProvider?: string; + inputs: Record; + outputs: Record; +}; + +export type TDeleteSecretRotationDTO = { + id: string; + workspaceId: string; +}; + +export type TRestartSecretRotationDTO = { + id: string; + workspaceId: string; +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index c61967eec..e38a7527f 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -12,6 +12,7 @@ export type SubscriptionPlan = { secretVersioning: boolean; slug: string; secretApproval: string; + secretRotation: string; tier: number; workspaceLimit: number; workspacesUsed: number; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index f6131e5b2..422219adf 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -13,6 +13,12 @@ export type { export { ApprovalStatus, CommitType } from "./secretApprovalRequest/types"; export type { TSecretFolder } from "./secretFolders/types"; export type { TImportedSecrets, TSecretImports } from "./secretImports/types"; +export type { + TGetSecretRotationProviders, + TProviderTemplate, + TSecretRotationProvider, + TSecretRotationProviderList +} from "./secretRotation/types"; export * from "./secrets/types"; export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types"; export type { SubscriptionPlan } from "./subscriptions/types"; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 04302548e..74f289209 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -497,6 +497,18 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + Secret rotation + + + { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: t("settings.project.title") })} + + + + +
+ ); +}; + +export default SecretRotation; + +SecretRotation.requireAuth = true; diff --git a/frontend/src/views/SecretRotationPage/SecretRotationPage.tsx b/frontend/src/views/SecretRotationPage/SecretRotationPage.tsx new file mode 100644 index 000000000..b83e30bde --- /dev/null +++ b/frontend/src/views/SecretRotationPage/SecretRotationPage.tsx @@ -0,0 +1,407 @@ +import { useTranslation } from "react-i18next"; +import { + faArrowsSpin, + faExclamationTriangle, + faFolder, + faInfoCircle, + faRotate, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { formatDistance } from "date-fns"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + EmptyState, + IconButton, + Modal, + ModalContent, + Skeleton, + Spinner, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr, + UpgradePlanModal +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { usePopUp } from "@app/hooks"; +import { + useDeleteSecretRotation, + useGetSecretRotationProviders, + useGetSecretRotations, + useGetUserWsKey, + useGetWorkspaceBot, + useRestartSecretRotation, + useUpdateBotActiveStatus +} from "@app/hooks/api"; +import { TSecretRotationProvider } from "@app/hooks/api/types"; + +import { CreateRotationForm } from "./components/CreateRotationForm"; +import { generateBotKey } from "./SecretRotationPage.utils"; + +export const SecretRotationPage = withProjectPermission( + () => { + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + const permission = useProjectPermission(); + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "createRotation", + "activeBot", + "deleteRotation", + "upgradePlan" + ] as const); + const workspaceId = currentWorkspace?._id || ""; + const canCreateRotation = permission.can( + ProjectPermissionActions.Create, + ProjectPermissionSub.SecretRotation + ); + const { subscription } = useSubscription(); + + const { data: userWsKey } = useGetUserWsKey(workspaceId); + + const { data: secretRotationProviders, isLoading: isRotationProviderLoading } = + useGetSecretRotationProviders({ workspaceId }); + const { data: secretRotations, isLoading: isRotationLoading } = useGetSecretRotations({ + workspaceId, + decryptFileKey: userWsKey! + }); + + const { + mutateAsync: deleteSecretRotation, + variables: deleteSecretRotationVars, + isLoading: isDeletingRotation + } = useDeleteSecretRotation(); + const { + mutateAsync: restartSecretRotation, + variables: restartSecretRotationVar, + isLoading: isRestartingRotation + } = useRestartSecretRotation(); + + const { data: bot } = useGetWorkspaceBot(workspaceId); + const { mutateAsync: updateBotActiveStatus } = useUpdateBotActiveStatus(); + + const isBotActive = Boolean(bot?.isActive); + + const handleDeleteRotation = async () => { + const { id } = popUp.deleteRotation.data as { id: string }; + try { + await deleteSecretRotation({ + id, + workspaceId + }); + handlePopUpClose("deleteRotation"); + createNotification({ + type: "success", + text: "Successfully removed rotation" + }); + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to remove rotation" + }); + } + }; + + const handleRestartRotation = async (id: string) => { + try { + await restartSecretRotation({ + id, + workspaceId + }); + createNotification({ + type: "success", + text: "Secret rotation initiated" + }); + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to restart rotation" + }); + } + }; + + const handleUserAcceptBotCondition = async () => { + const provider = popUp.activeBot?.data as TSecretRotationProvider; + try { + if (bot?._id) { + const botKey = generateBotKey(bot.publicKey, userWsKey!); + await updateBotActiveStatus({ + isActive: true, + botId: bot._id, + workspaceId, + botKey + }); + } + handlePopUpOpen("createRotation", provider); + handlePopUpClose("activeBot"); + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to create bot" + }); + } + }; + + const handleCreateRotation = async (provider: TSecretRotationProvider) => { + if (subscription && !subscription?.secretRotation) { + handlePopUpOpen("upgradePlan"); + return; + } + if (!canCreateRotation) { + createNotification({ type: "error", text: "Access permission denied!!" }); + return; + } + if (isBotActive) { + handlePopUpOpen("createRotation", provider); + } else { + handlePopUpOpen("activeBot", provider); + } + }; + + return ( +
+
+

Secret Rotation

+

Auto rotate secrets for better security

+
+
+
Rotated Secrets
+
+ + + + + + + + + + + + + + {isRotationLoading && ( + + )} + {!isRotationLoading && secretRotations?.length === 0 && ( + + + + )} + {secretRotations?.map( + ({ + environment, + secretPath, + outputs, + provider, + _id, + lastRotatedAt, + status, + statusMessage + }) => { + const isDeleting = deleteSecretRotationVars?.id === _id && isDeletingRotation; + const isRestarting = + restartSecretRotationVar?.id === _id && isRestartingRotation; + return ( + + + + + + + + + ); + } + )} + +
Secret NameEnvironmentProviderStatusLast RotationAction
+ +
+ {outputs + .map(({ key }) => key) + .join(",") + .toUpperCase()} + +
+
{environment}
+
+ + {secretPath} +
+
+
{provider} +
+ {status} + {status === "failed" && ( + + + + )} +
+
+ {lastRotatedAt + ? formatDistance(new Date(lastRotatedAt), new Date()) + : "-"} + +
+ + {(isAllowed) => ( + handleRestartRotation(_id)} + > + {isRestarting ? ( + + ) : ( + + )} + + )} + + + {(isAllowed) => ( + handlePopUpOpen("deleteRotation", { id: _id })} + > + {isDeleting ? ( + + ) : ( + + )} + + )} + +
+
+
+
+
+
Infisical Rotation Providers
+
+ {isRotationProviderLoading && + Array.from({ length: 12 }).map((_, index) => ( + + ))} + {!isRotationProviderLoading && + secretRotationProviders?.providers.map((provider) => ( +
{ + if (evt.key === "Enter") handlePopUpOpen("createRotation", provider); + }} + onClick={() => handleCreateRotation(provider)} + > + rotation provider logo +
+ {provider.title} +
+
+ + + +
+
+ ))} +
+ handlePopUpToggle("createRotation", isOpen)} + provider={(popUp.createRotation.data as TSecretRotationProvider) || {}} + /> + handlePopUpToggle("activeBot", isOpen)} + > + + + +
+ } + > + {t("integrations.why-infisical-needs-access")} + + + handlePopUpToggle("deleteRotation", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleDeleteRotation} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add secret rotation if you switch to Infisical's Team plan." + /> +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SecretRotation } +); diff --git a/frontend/src/views/SecretRotationPage/SecretRotationPage.utils.ts b/frontend/src/views/SecretRotationPage/SecretRotationPage.utils.ts new file mode 100644 index 000000000..6dd346f8e --- /dev/null +++ b/frontend/src/views/SecretRotationPage/SecretRotationPage.utils.ts @@ -0,0 +1,30 @@ +import { UserWsKeyPair } from "@app/hooks/api/types"; + +import { + decryptAssymmetric, + encryptAssymmetric +} from "../../components/utilities/cryptography/crypto"; + +// refactor these to common function in frontend +export const generateBotKey = (botPublicKey: string, latestKey: UserWsKeyPair) => { + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); + + if (!PRIVATE_KEY) { + throw new Error("Private Key missing"); + } + + const WORKSPACE_KEY = decryptAssymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: WORKSPACE_KEY, + publicKey: botPublicKey, + privateKey: PRIVATE_KEY + }); + + return { encryptedKey: ciphertext, nonce }; +}; diff --git a/frontend/src/views/SecretRotationPage/components/CreateRotationForm/CreateRotationForm.tsx b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/CreateRotationForm.tsx new file mode 100644 index 000000000..c5493159e --- /dev/null +++ b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/CreateRotationForm.tsx @@ -0,0 +1,149 @@ +import { useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; + +import { Modal, ModalContent, Step, Stepper } from "@app/components/v2"; +import { useCreateSecretRotation } from "@app/hooks/api"; +import { TSecretRotationProvider } from "@app/hooks/api/types"; + +import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider"; + +import { GeneralDetailsForm, TFormSchema as TGeneralFormSchema } from "./steps/GeneralDetailsForm"; +import { RotationInputForm } from "./steps/RotationInputForm"; +import { + RotationOutputForm, + TFormSchema as TRotationOutputSchema +} from "./steps/RotationOutputForm"; + +const WIZARD_STEPS = [ + { + title: "General" + }, + { + title: "Inputs" + }, + { + title: "Secret Mapping" + } +]; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + customProvider?: string; + workspaceId: string; + provider: TSecretRotationProvider; +}; + +export const CreateRotationForm = ({ + isOpen, + onToggle, + provider, + workspaceId, + customProvider +}: Props) => { + const [wizardStep, setWizardStep] = useState(0); + const wizardData = useRef<{ + general?: TGeneralFormSchema; + input?: Record; + output?: TRotationOutputSchema; + }>({}); + const { createNotification } = useNotificationContext(); + + const { mutateAsync: createSecretRotation } = useCreateSecretRotation(); + + const handleFormCancel = () => { + onToggle(false); + setWizardStep(0); + wizardData.current = {}; + }; + + const handleFormSubmit = async () => { + if (!wizardData.current.general || !wizardData.current.input || !wizardData.current.output) + return; + try { + await createSecretRotation({ + workspaceId, + provider: provider.name, + customProvider, + secretPath: wizardData.current.general.secretPath, + environment: wizardData.current.general.environment, + interval: wizardData.current.general.interval, + inputs: wizardData.current.input, + outputs: wizardData.current.output + }); + setWizardStep(0); + onToggle(false); + wizardData.current = {}; + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to create secret rotation" + }); + } + }; + + return ( + { + onToggle(state); + setWizardStep(0); + wizardData.current = {}; + }} + > + + + {WIZARD_STEPS.map(({ title }, index) => ( + + ))} + + + {wizardStep === 0 && ( + + { + wizardData.current.general = data; + setWizardStep((state) => state + 1); + }} + /> + + )} + {wizardStep === 1 && ( + { + wizardData.current.input = data; + setWizardStep((state) => state + 1); + }} + inputSchema={provider.template?.inputs || {}} + /> + )} + {wizardStep === 2 && ( + { + wizardData.current.output = data; + await handleFormSubmit(); + }} + /> + )} + + + + ); +}; diff --git a/frontend/src/views/SecretRotationPage/components/CreateRotationForm/index.tsx b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/index.tsx new file mode 100644 index 000000000..8392c4cdb --- /dev/null +++ b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/index.tsx @@ -0,0 +1 @@ +export { CreateRotationForm } from "./CreateRotationForm"; diff --git a/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/GeneralDetailsForm.tsx b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/GeneralDetailsForm.tsx new file mode 100644 index 000000000..b83b19974 --- /dev/null +++ b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/GeneralDetailsForm.tsx @@ -0,0 +1,93 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; + +const formSchema = z.object({ + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + interval: z.number() +}); + +export type TFormSchema = z.infer; +type Props = { + onSubmit: (data: TFormSchema) => void; + onCancel: () => void; +}; + +export const GeneralDetailsForm = ({ onSubmit, onCancel }: Props) => { + const { currentWorkspace } = useWorkspace(); + const environments = currentWorkspace?.environments || []; + const { + control, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + field.onChange(parseInt(evt.target.value, 10))} + /> + + )} + /> +
+ + +
+ + ); +}; diff --git a/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/RotationInputForm.tsx b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/RotationInputForm.tsx new file mode 100644 index 000000000..baacfc2a5 --- /dev/null +++ b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/RotationInputForm.tsx @@ -0,0 +1,61 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, SecretInput } from "@app/components/v2"; + +type Props = { + onSubmit: (data: Record) => void; + onCancel: () => void; + inputSchema: { + properties: Record; + required: string[]; + }; +}; + +const formSchema = z.record(z.string().trim().optional()); + +export const RotationInputForm = ({ onSubmit, onCancel, inputSchema }: Props) => { + const { + control, + handleSubmit, + formState: { isSubmitting } + } = useForm>({ + resolver: zodResolver(formSchema) + }); + + return ( +
+ {Object.keys(inputSchema.properties || {}).map((inputName) => ( + ( + + + + )} + /> + ))} +
+ + +
+ + ); +}; diff --git a/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/RotationOutputForm.tsx b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/RotationOutputForm.tsx new file mode 100644 index 000000000..ed60ba912 --- /dev/null +++ b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/RotationOutputForm.tsx @@ -0,0 +1,80 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, Select, SelectItem } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api"; + +const formSchema = z.record(z.string()); + +export type TFormSchema = z.infer; +type Props = { + environment: string; + secretPath: string; + outputSchema: Record; + onSubmit: (data: TFormSchema) => void; + onCancel: () => void; +}; + +export const RotationOutputForm = ({ + onSubmit, + onCancel, + environment, + secretPath, + outputSchema = {} +}: Props) => { + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + const { + control, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const { data: userWsKey } = useGetUserWsKey(workspaceId); + const { data: secrets } = useGetProjectSecrets({ + workspaceId, + environment, + secretPath, + decryptFileKey: userWsKey! + }); + + return ( +
+ {Object.keys(outputSchema).map((outputName) => ( + ( + + + + )} + /> + ))} +
+ + +
+ + ); +}; diff --git a/frontend/src/views/SecretRotationPage/index.tsx b/frontend/src/views/SecretRotationPage/index.tsx new file mode 100644 index 000000000..c2f41e76e --- /dev/null +++ b/frontend/src/views/SecretRotationPage/index.tsx @@ -0,0 +1 @@ +export { SecretRotationPage } from "./SecretRotationPage";