Merge branch 'main' into fix/mrg-bug-fixes

This commit is contained in:
Maidul Islam
2023-11-02 12:44:26 -04:00
committed by GitHub
91 changed files with 5241 additions and 2084 deletions

View File

@@ -9,16 +9,24 @@ export type FormLabelProps = {
isRequired?: boolean;
label?: ReactNode;
icon?: ReactNode;
className?: string;
};
export const FormLabel = ({ id, label, isRequired, icon }: FormLabelProps) => (
export const FormLabel = ({ id, label, isRequired, icon, className }: FormLabelProps) => (
<Label.Root
className="mb-0.5 ml-1 block flex items-center text-sm font-normal text-mineshaft-400"
className={twMerge(
"mb-0.5 ml-1 block flex items-center text-sm font-normal text-mineshaft-400",
className
)}
htmlFor={id}
>
{label}
{isRequired && <span className="ml-1 text-red">*</span>}
{icon && <span className="ml-2 text-mineshaft-300 hover:text-mineshaft-200 cursor-default">{icon}</span>}
{icon && (
<span className="ml-2 text-mineshaft-300 hover:text-mineshaft-200 cursor-default">
{icon}
</span>
)}
</Label.Root>
);

View File

@@ -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 (
<div
className={twMerge(
"flex items-center w-full space-x-3 p-2 border border-bunker-300/30 rounded-md",
className
)}
>
{Children.map(children as ReactNode, (child: ReactNode, index) => {
const isCompleted = activeStep > index;
const isActive = index === activeStep;
const isNotLast = index + 1 !== (children as Array<ReactNode>).length;
return (
<div
className={twMerge(
"flex items-center space-x-3 flex-shrink-0",
isNotLast && "flex-grow"
)}
>
<div className="flex items-center space-x-2 flex-shrink-0">
<div
className={twMerge(
"w-7 h-7 flex items-center justify-center font-medium text-mineshaft-800 text-sm rounded-full transition-all",
isCompleted ? "bg-primary" : "border text-bunker-300 border-primary/30",
isActive && "bg-primary text-mineshaft-800"
)}
>
{isCompleted ? <FontAwesomeIcon icon={faCheck} /> : index + 1}
</div>
{cloneElement(child as ReactElement, {
direction,
activeStep,
isCompleted,
isActive
})}
</div>
{isNotLast && (
<div
style={{ height: "1px" }}
className={twMerge("flex-grow bg-bunker-300/30", isCompleted && "bg-primary")}
/>
)}
</div>
);
})}
</div>
);
};
export type StepProps = {
title: string;
description?: ReactNode;
// isActive?: boolean;
// isCompleted?: boolean;
// activeStep?: number;
// direction?: "vertical" | "horizontal";
};
export const Step = ({ title, description }: StepProps) => {
return (
<div className="flex flex-col text-gray-300">
<div className="font-medium text-sm">{title}</div>
{description && <div className="text-xs">{description}</div>}
</div>
);
};

View File

@@ -0,0 +1,2 @@
export type { StepperProps,StepProps } from "./Stepper";
export { Step,Stepper } from "./Stepper";

View File

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

View File

@@ -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]

View File

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

View File

@@ -4,6 +4,7 @@ export {
useGetIntegrationAuthApps,
useGetIntegrationAuthBitBucketWorkspaces,
useGetIntegrationAuthById,
useGetIntegrationAuthChecklyGroups,
useGetIntegrationAuthNorthflankSecretGroups,
useGetIntegrationAuthRailwayEnvironments,
useGetIntegrationAuthRailwayServices,
@@ -11,4 +12,4 @@ export {
useGetIntegrationAuthTeams,
useGetIntegrationAuthVercelBranches,
useSaveIntegrationAccessToken
} from "./queries";
} from "./queries";

View File

@@ -6,14 +6,16 @@ import { workspaceKeys } from "../workspace/queries";
import {
App,
BitBucketWorkspace,
ChecklyGroup,
Environment,
IntegrationAuth,
NorthflankSecretGroup,
Org,
Project,
Service,
Team,
TeamCityBuildConfig} from "./types";
Team,
TeamCityBuildConfig
} from "./types";
const integrationAuthKeys = {
getIntegrationAuthById: (integrationAuthId: string) =>
@@ -29,6 +31,14 @@ const integrationAuthKeys = {
integrationAuthId: string;
appId: string;
}) => [{ integrationAuthId, appId }, "integrationAuthVercelBranches"] as const,
getIntegrationAuthChecklyGroups: ({
integrationAuthId,
accountId
}: {
integrationAuthId: string;
accountId: string;
}) =>
[{ integrationAuthId, accountId }, "integrationAuthChecklyGroups"] as const,
getIntegrationAuthQoveryOrgs: (integrationAuthId: string) =>
[{ integrationAuthId }, "integrationAuthQoveryOrgs"] as const,
getIntegrationAuthQoveryProjects: ({
@@ -125,6 +135,24 @@ const fetchIntegrationAuthTeams = async (integrationAuthId: string) => {
return data.teams;
};
const fetchIntegrationAuthChecklyGroups = async ({
integrationAuthId,
accountId
}: {
integrationAuthId: string;
accountId: string;
}) => {
const { data } = await apiRequest.get<{ groups: ChecklyGroup[] }>(
`/api/v1/integration-auth/${integrationAuthId}/checkly/groups`,
{
params: {
accountId
}
}
);
return data.groups;
};
const fetchIntegrationAuthVercelBranches = async ({
integrationAuthId,
@@ -413,6 +441,26 @@ export const useGetIntegrationAuthVercelBranches = ({
});
};
export const useGetIntegrationAuthChecklyGroups = ({
integrationAuthId,
accountId
}: {
integrationAuthId: string;
accountId: string;
}) => {
return useQuery({
queryKey: integrationAuthKeys.getIntegrationAuthChecklyGroups({
integrationAuthId,
accountId
}),
queryFn: () => fetchIntegrationAuthChecklyGroups({
integrationAuthId,
accountId
}),
enabled: true
});
};
export const useGetIntegrationAuthQoveryOrgs = (integrationAuthId: string) => {
return useQuery({
queryKey: integrationAuthKeys.getIntegrationAuthQoveryOrgs(integrationAuthId),

View File

@@ -26,6 +26,11 @@ export type Environment = {
environmentId: string;
};
export type ChecklyGroup = {
name: string;
groupId: number;
};
export type Container = {
name: string;
containerId: string;

View File

@@ -0,0 +1,6 @@
export {
useCreateSecretRotation,
useDeleteSecretRotation,
useRestartSecretRotation
} from "./mutation";
export { useGetSecretRotationProviders, useGetSecretRotations } from "./queries";

View File

@@ -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 }));
}
});
};

View File

@@ -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<TGetSecretRotationList, "decryptFileKey">) =>
[{ workspaceId }, "secret-rotations"] as const
};
const fetchSecretRotationProviders = async ({ workspaceId }: TGetSecretRotationProviders) => {
const { data } = await apiRequest.get<TSecretRotationProviderList>(
`/api/v1/secret-rotation-providers/${workspaceId}`
);
return data;
};
export const useGetSecretRotationProviders = ({
workspaceId,
options = {}
}: TGetSecretRotationProviders & {
options?: Omit<
UseQueryOptions<
TSecretRotationProviderList,
unknown,
TSecretRotationProviderList,
ReturnType<typeof secretRotationKeys.listProviders>
>,
"queryKey" | "queryFn"
>;
}) =>
useQuery({
...options,
queryKey: secretRotationKeys.listProviders({ workspaceId }),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
queryFn: async () => fetchSecretRotationProviders({ workspaceId })
});
const fetchSecretRotations = async ({
workspaceId
}: Omit<TGetSecretRotationList, "decryptFileKey">) => {
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<typeof secretRotationKeys.list>
>,
"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]
)
});

View File

@@ -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<string, string>;
query?: Record<string, string>;
body?: Record<string, unknown>;
setter?: Record<string, TAssignFunction>;
pre?: Record<string, TDirectAssignOp>;
};
export type TDbProviderFunction = {
type: TProviderFunctionTypes.DB;
client: TDbProviderClients;
username: string;
password: string;
host: string;
database: string;
port: string;
query: string;
setter?: Record<string, TAssignFunction>;
pre?: Record<string, TDirectAssignOp>;
};
export type TProviderFunction = THttpProviderFunction | TDbProviderFunction;
export type TProviderTemplate = {
inputs: {
properties: Record<string, { type: string; helperText?: string; defaultValue?: string }>;
type: "object";
required: string[];
};
outputs: Record<string, unknown>;
functions: {
set: TProviderFunction;
remove?: TProviderFunction;
test: TProviderFunction;
};
};
export type TSecretRotation<T extends unknown = EncryptedSecret> = {
_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<string, unknown>;
outputs: Record<string, string>;
};
export type TDeleteSecretRotationDTO = {
id: string;
workspaceId: string;
};
export type TRestartSecretRotationDTO = {
id: string;
workspaceId: string;
};

View File

@@ -12,6 +12,7 @@ export type SubscriptionPlan = {
secretVersioning: boolean;
slug: string;
secretApproval: string;
secretRotation: string;
tier: number;
workspaceLimit: number;
workspacesUsed: number;

View File

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

View File

@@ -319,7 +319,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
</Button>
</DropdownMenuItem>
))}
<DropdownMenuItem key="add-org">
{/* <DropdownMenuItem key="add-org">
<Button
onClick={() => handlePopUpOpen("createOrg")}
variant="plain"
@@ -334,7 +334,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
Create New Organization
</div>
</Button>
</DropdownMenuItem>
</DropdownMenuItem> */}
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<button type="button" onClick={logOutUser} className="w-full">
<DropdownMenuItem>Log Out</DropdownMenuItem>
@@ -497,6 +497,18 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem>
</a>
</Link>
<Link href={`/project/${currentWorkspace?._id}/secret-rotation`} passHref>
<a className="relative">
<MenuItem
isSelected={
router.asPath === `/project/${currentWorkspace?._id}/secret-rotation`
}
icon="rotation"
>
Secret Rotation
</MenuItem>
</a>
</Link>
<Link href={`/project/${currentWorkspace?._id}/approval`} passHref>
<a className="relative">
<MenuItem
@@ -505,7 +517,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
}
icon="system-outline-189-domain-verification"
>
Secret approvals
Secret Approvals
{Boolean(secretApprovalReqCount?.open) && (
<span className="ml-2 rounded border border-primary-400 bg-primary-600 py-0.5 px-1 text-xs font-semibold text-black">
{secretApprovalReqCount?.open}

View File

@@ -3,7 +3,7 @@ import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowUpRightFromSquare, faBookOpen, faBugs, faCircleInfo } from "@fortawesome/free-solid-svg-icons";
import { faArrowUpRightFromSquare, faBookOpen, faBugs } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import queryString from "query-string";
@@ -27,7 +27,8 @@ import {
import {
useGetIntegrationAuthApps,
useGetIntegrationAuthById
useGetIntegrationAuthById,
useGetIntegrationAuthChecklyGroups
} from "../../../hooks/api/integrationAuth";
import { useGetWorkspaceById } from "../../../hooks/api/workspace";
@@ -42,20 +43,24 @@ export default function ChecklyCreateIntegrationPage() {
const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]);
const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState("");
const [secretPath, setSecretPath] = useState("/");
const [secretSuffix, setSecretSuffix] = useState("");
const [targetAppId, setTargetAppId] = useState("");
const [targetGroupId, setTargetGroupId] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? "");
const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? "");
const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = useGetIntegrationAuthApps({
integrationAuthId: (integrationAuthId as string) ?? ""
});
const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState("");
const [secretPath, setSecretPath] = useState("/");
const [secretSuffix, setSecretSuffix] = useState("");
const [targetApp, setTargetApp] = useState("");
const [targetAppId, setTargetAppId] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { data: integrationAuthGroups, isLoading: isintegrationAuthGroupsLoading } = useGetIntegrationAuthChecklyGroups({
integrationAuthId: (integrationAuthId as string) ?? "",
accountId: targetAppId
});
useEffect(() => {
if (workspace) {
@@ -64,13 +69,11 @@ export default function ChecklyCreateIntegrationPage() {
}, [workspace]);
useEffect(() => {
// TODO: handle case where apps can be empty
if (integrationAuthApps) {
if (integrationAuthApps.length > 0) {
setTargetApp(integrationAuthApps[0].name);
setTargetAppId(String(integrationAuthApps[0].appId));
setTargetAppId(integrationAuthApps[0].appId as string);
} else {
setTargetApp("none");
setTargetAppId("none");
}
}
}, [integrationAuthApps]);
@@ -81,12 +84,23 @@ export default function ChecklyCreateIntegrationPage() {
setIsLoading(true);
const targetApp = integrationAuthApps?.find(
(integrationAuthApp) => integrationAuthApp.appId === targetAppId
);
const targetGroup = integrationAuthGroups?.find(
(group) => group.groupId === Number(targetGroupId)
);
if (!targetApp) return;
await mutateAsync({
integrationAuthId: integrationAuth?._id,
isActive: true,
app: targetApp,
appId: targetAppId,
app: targetApp?.name,
appId: targetApp?.appId,
sourceEnvironment: selectedSourceEnvironment,
targetService: targetGroup?.name,
targetServiceId: targetGroup?.groupId ? String(targetGroup?.groupId) : undefined,
secretPath,
metadata: {
secretSuffix
@@ -104,9 +118,10 @@ export default function ChecklyCreateIntegrationPage() {
return integrationAuth &&
workspace &&
selectedSourceEnvironment &&
integrationAuthApps &&
targetApp ? (
<div className="flex flex-col h-full w-full items-center justify-center bg-gradient-to-tr from-mineshaft-900 to-bunker-900">
integrationAuthApps &&
integrationAuthGroups &&
targetAppId ? (
<div className="flex h-full flex-col w-full py-6 items-center justify-center bg-gradient-to-tr from-mineshaft-900 to-bunker-900">
<Head>
<title>Set Up Checkly Integration</title>
<link rel='icon' href='/infisical.ico' />
@@ -177,16 +192,16 @@ export default function ChecklyCreateIntegrationPage() {
</FormControl>
<FormControl label="Checkly Account">
<Select
value={targetApp}
onValueChange={(val) => setTargetApp(val)}
value={targetAppId}
onValueChange={(val) => setTargetAppId(val)}
className="w-full border border-mineshaft-500"
isDisabled={integrationAuthApps.length === 0}
>
{integrationAuthApps.length > 0 ? (
integrationAuthApps.map((integrationAuthApp) => (
<SelectItem
value={integrationAuthApp.name}
key={`target-app-${integrationAuthApp.name}`}
value={integrationAuthApp.appId as string}
key={`target-app-${integrationAuthApp.appId as string}`}
>
{integrationAuthApp.name}
</SelectItem>
@@ -198,6 +213,28 @@ export default function ChecklyCreateIntegrationPage() {
)}
</Select>
</FormControl>
<FormControl label="Checkly Group (Optional)">
<Select
value={targetGroupId}
onValueChange={(val) => setTargetGroupId(val)}
className="w-full border border-mineshaft-500"
>
{integrationAuthGroups.length > 0 ? (
integrationAuthGroups.map((integrationAuthGroup) => (
<SelectItem
value={String(integrationAuthGroup.groupId)}
key={`target-group-${String(integrationAuthGroup.groupId)}`}
>
{integrationAuthGroup.name}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-group-none">
No groups found
</SelectItem>
)}
</Select>
</FormControl>
</motion.div>
</TabPanel>
<TabPanel value={TabSections.Options}>
@@ -229,12 +266,6 @@ export default function ChecklyCreateIntegrationPage() {
Create Integration
</Button>
</Card>
<div className="border-t border-mineshaft-800 w-full max-w-md mt-6"/>
<div className="flex flex-col bg-mineshaft-800 border border-mineshaft-600 w-full p-4 max-w-lg mt-6 rounded-md">
<div className="flex flex-row items-center"><FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-200 text-xl"/> <span className="ml-3 text-md text-mineshaft-100">Pro Tips</span></div>
<span className="text-mineshaft-300 text-sm mt-4">After creating an integration, your secrets will start syncing immediately. This might cause an unexpected override of current secrets in Checkly with secrets from Infisical.</span>
<span className="text-mineshaft-300 text-sm mt-4">If you have multiple Checkly integrations and are using suffixes for at least one of them, you will have to add suffixes for all the active Checkly integrations – otherwise you might run into rare unexpected behavior.</span>
</div>
</div>
) : (
<div className="flex justify-center items-center w-full h-full">
@@ -242,7 +273,7 @@ export default function ChecklyCreateIntegrationPage() {
<title>Set Up Checkly Integration</title>
<link rel='icon' href='/infisical.ico' />
</Head>
{isIntegrationAuthAppsLoading ? <img src="/images/loading/loading.gif" height={70} width={120} alt="infisical loading indicator" /> : <div className="max-w-md h-max p-6 border border-mineshaft-600 rounded-md bg-mineshaft-800 text-mineshaft-200 flex flex-col text-center">
{isIntegrationAuthAppsLoading || isintegrationAuthGroupsLoading ? <img src="/images/loading/loading.gif" height={70} width={120} alt="infisical loading indicator" /> : <div className="max-w-md h-max p-6 border border-mineshaft-600 rounded-md bg-mineshaft-800 text-mineshaft-200 flex flex-col text-center">
<FontAwesomeIcon icon={faBugs} className="text-6xl my-2 inlineli"/>
<p>
Something went wrong. Please contact <a

View File

@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { SecretRotationPage } from "@app/views/SecretRotationPage";
const SecretRotation = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Head>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Head>
<SecretRotationPage />
</div>
);
};
export default SecretRotation;
SecretRotation.requireAuth = true;

View File

@@ -162,14 +162,23 @@ export const IntegrationsSection = ({
</div>
</div>
)}
{(integration.integration === "checkly" ||
integration.integration === "github") && (
<div className="ml-2 flex flex-col">
<FormLabel label="Secret Suffix" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.metadata?.secretSuffix || "-"}
{((integration.integration === "checkly") || (integration.integration === "github")) && (
<>
{integration.targetService && (
<div className="ml-2">
<FormLabel label="Group" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService}
</div>
</div>
)}
<div className="ml-2">
<FormLabel label="Secret Suffix" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.metadata?.secretSuffix || "-"}
</div>
</div>
</div>
</>
)}
</div>
<div className="flex cursor-default items-center">

View File

@@ -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 (
<div className="container mx-auto bg-bunker-800 text-white w-full h-full max-w-7xl px-6">
<div className="my-6">
<h2 className="text-3xl font-semibold text-gray-200">Secret Rotation</h2>
<p className="text-bunker-300">Auto rotate secrets for better security</p>
</div>
<div className="mb-6">
<div className="text-xl font-semibold text-gray-200 mb-2">Rotated Secrets</div>
<div className="flex flex-col space-y-2">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Secret Name</Th>
<Th>Environment</Th>
<Th>Provider</Th>
<Th>Status</Th>
<Th>Last Rotation</Th>
<Th className="text-right">Action</Th>
</Tr>
</THead>
<TBody>
{isRotationLoading && (
<TableSkeleton
innerKey="secret-rotation-loading"
columns={6}
className="bg-mineshaft-700"
/>
)}
{!isRotationLoading && secretRotations?.length === 0 && (
<Tr>
<Td colSpan={6}>
<EmptyState title="No rotation strategy found" icon={faArrowsSpin} />
</Td>
</Tr>
)}
{secretRotations?.map(
({
environment,
secretPath,
outputs,
provider,
_id,
lastRotatedAt,
status,
statusMessage
}) => {
const isDeleting = deleteSecretRotationVars?.id === _id && isDeletingRotation;
const isRestarting =
restartSecretRotationVar?.id === _id && isRestartingRotation;
return (
<Tr key={_id}>
<Td>
{outputs
.map(({ key }) => key)
.join(",")
.toUpperCase()}
</Td>
<Td>
<div className="flex items-center border border-bunker-400 rounded p-1 px-2 w-min">
<div>{environment}</div>
<div className="flex items-center border-l border-bunker-400 pl-1 ml-1 text-xs">
<FontAwesomeIcon icon={faFolder} className="mr-1" />
{secretPath}
</div>
</div>
</Td>
<Td>{provider}</Td>
<Td>
<div className="flex items-center">
{status}
{status === "failed" && (
<Tooltip content={statusMessage}>
<FontAwesomeIcon
icon={faExclamationTriangle}
size="sm"
className="ml-2 text-red"
/>
</Tooltip>
)}
</div>
</Td>
<Td>
{lastRotatedAt
? formatDistance(new Date(lastRotatedAt), new Date())
: "-"}
</Td>
<Td>
<div className="flex space-x-2 justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretRotation}
allowedLabel="Rotate now"
renderTooltip
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="danger"
ariaLabel="delete-rotation"
isDisabled={isDeleting || !isAllowed}
onClick={() => handleRestartRotation(_id)}
>
{isRestarting ? (
<Spinner size="xs" />
) : (
<FontAwesomeIcon icon={faRotate} />
)}
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretRotation}
allowedLabel="Rotate now"
renderTooltip
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="danger"
ariaLabel="delete-rotation"
isDisabled={isDeleting || !isAllowed}
onClick={() => handlePopUpOpen("deleteRotation", { id: _id })}
>
{isDeleting ? (
<Spinner size="xs" />
) : (
<FontAwesomeIcon icon={faTrash} />
)}
</IconButton>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
}
)}
</TBody>
</Table>
</TableContainer>
</div>
</div>
<div className="text-xl font-semibold text-gray-200 mb-2">Infisical Rotation Providers</div>
<div className="grid grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4">
{isRotationProviderLoading &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton className="h-32" key={`rotation-provider-skeleton-${index + 1}`} />
))}
{!isRotationProviderLoading &&
secretRotationProviders?.providers.map((provider) => (
<div
className="group relative cursor-pointer h-32 flex flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
key={`infisical-rotation-provider-${provider.name}`}
tabIndex={0}
role="button"
onKeyDown={(evt) => {
if (evt.key === "Enter") handlePopUpOpen("createRotation", provider);
}}
onClick={() => handleCreateRotation(provider)}
>
<img
src={`/images/secretRotation/${provider.image}`}
height={70}
width={70}
alt="rotation provider logo"
/>
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
{provider.title}
</div>
<div className="group-hover:opacity-100 transition-all opacity-0 absolute top-1 right-1">
<Tooltip content={provider.description} sideOffset={10}>
<FontAwesomeIcon icon={faInfoCircle} className="text-bunker-300" />
</Tooltip>
</div>
</div>
))}
</div>
<CreateRotationForm
isOpen={popUp.createRotation.isOpen}
workspaceId={workspaceId}
onToggle={(isOpen) => handlePopUpToggle("createRotation", isOpen)}
provider={(popUp.createRotation.data as TSecretRotationProvider) || {}}
/>
<Modal
isOpen={popUp.activeBot?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("activeBot", isOpen)}
>
<ModalContent
title={t("integrations.grant-access-to-secrets") as string}
footerContent={
<div className="flex items-center space-x-2">
<Button onClick={() => handleUserAcceptBotCondition()}>
{t("integrations.grant-access-button") as string}
</Button>
<Button
onClick={() => handlePopUpClose("activeBot")}
variant="outline_bg"
colorSchema="secondary"
>
Cancel
</Button>
</div>
}
>
{t("integrations.why-infisical-needs-access")}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deleteRotation.isOpen}
title="Are you sure want to delete this rotation?"
subTitle="This will stop the rotation from dynamically changing. Secret won't be deleted"
onChange={(isOpen) => handlePopUpToggle("deleteRotation", isOpen)}
deleteKey="delete"
onDeleteApproved={handleDeleteRotation}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret rotation if you switch to Infisical's Team plan."
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SecretRotation }
);

View File

@@ -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 };
};

View File

@@ -0,0 +1,146 @@
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 { RotationInputForm } from "./steps/RotationInputForm";
import {
RotationOutputForm,
TFormSchema as TRotationOutputSchema
} from "./steps/RotationOutputForm";
const WIZARD_STEPS = [
{
title: "Inputs",
description: "Provider secrets"
},
{
title: "Outputs",
description: "Map rotated secrets to keys"
}
];
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<{
input?: Record<string, string>;
output?: TRotationOutputSchema;
}>({});
const { createNotification } = useNotificationContext();
const { mutateAsync: createSecretRotation } = useCreateSecretRotation();
const handleFormCancel = () => {
onToggle(false);
setWizardStep(0);
wizardData.current = {};
};
const handleFormSubmit = async () => {
if (!wizardData.current.input || !wizardData.current.output) return;
try {
await createSecretRotation({
workspaceId,
provider: provider.name,
customProvider,
secretPath: wizardData.current.output.secretPath,
environment: wizardData.current.output.environment,
interval: wizardData.current.output.interval,
inputs: wizardData.current.input,
outputs: wizardData.current.output.secrets
});
setWizardStep(0);
onToggle(false);
wizardData.current = {};
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to create secret rotation"
});
}
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(state) => {
onToggle(state);
setWizardStep(0);
wizardData.current = {};
}}
>
<ModalContent
title={`Secret rotation for ${provider.name}`}
subTitle="Provide the required inputs needed for the rotation"
className="max-w-2xl"
>
<Stepper activeStep={wizardStep} direction="horizontal" className="mb-4">
{WIZARD_STEPS.map(({ title, description }, index) => (
<Step
title={title}
description={description}
key={`wizard-stepper-rotation-${index + 1}`}
/>
))}
</Stepper>
<AnimatePresence exitBeforeEnter>
{wizardStep === 0 && (
<motion.div
key="input-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<RotationInputForm
onCancel={handleFormCancel}
onSubmit={(data) => {
wizardData.current.input = data;
setWizardStep((state) => state + 1);
}}
inputSchema={provider.template?.inputs || {}}
/>
</motion.div>
)}
{wizardStep === 1 && (
<motion.div
key="output-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<RotationOutputForm
outputSchema={provider.template?.outputs || {}}
onCancel={handleFormCancel}
onSubmit={async (data) => {
wizardData.current.output = data;
await handleFormSubmit();
}}
/>
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1 @@
export { CreateRotationForm } from "./CreateRotationForm";

View File

@@ -0,0 +1,78 @@
import { Controller, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, FormLabel, SecretInput, Tooltip } from "@app/components/v2";
type Props = {
onSubmit: (data: Record<string, string>) => void;
onCancel: () => void;
inputSchema: {
properties: Record<string, { type: string; desc?: string; default?: string }>;
required: string[];
};
};
const formSchema = z.record(z.string().trim().optional());
export const RotationInputForm = ({ onSubmit, onCancel, inputSchema }: Props) => {
const {
control,
handleSubmit,
formState: { isSubmitting }
} = useForm<Record<string, string>>({
resolver: zodResolver(formSchema)
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{Object.keys(inputSchema.properties || {}).map((inputName) => (
<Controller
control={control}
name={inputName}
key={`provider-input-${inputName}`}
defaultValue={inputSchema.properties[inputName]?.default}
render={({ field }) => (
<FormControl
key={`provider-input-${inputName}`}
label={
<div className="flex items-center space-x-2">
<FormLabel className="uppercase mb-0" label={inputName.replaceAll("_", " ")} />
{Boolean(inputSchema.properties[inputName]?.desc) && (
<Tooltip
className="max-w-xs"
content={inputSchema.properties[inputName]?.desc}
position="right"
>
<FontAwesomeIcon
icon={faQuestionCircle}
size="xs"
className="text-bunker-300"
/>
</Tooltip>
)}
</div>
}
>
<SecretInput
{...field}
containerClassName="normal-case text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
required={inputSchema.required.includes(inputName)}
/>
</FormControl>
)}
/>
))}
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Next
</Button>
<Button onClick={onCancel} colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,153 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, Input, Select, SelectItem, Spinner } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api";
const formSchema = z.object({
environment: z.string().trim(),
secretPath: z.string().trim().default("/"),
interval: z.number().min(1),
secrets: z.record(z.string())
});
export type TFormSchema = z.infer<typeof formSchema>;
type Props = {
outputSchema: Record<string, unknown>;
onSubmit: (data: TFormSchema) => void;
onCancel: () => void;
};
export const RotationOutputForm = ({ onSubmit, onCancel, outputSchema = {} }: Props) => {
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const workspaceId = currentWorkspace?._id || "";
const {
control,
handleSubmit,
watch,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema)
});
const environment = watch("environment", environments?.[0]?.slug);
const secretPath = watch("secretPath");
const selectedSecrets = watch("secrets");
const { data: userWsKey } = useGetUserWsKey(workspaceId);
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
workspaceId,
environment,
secretPath,
decryptFileKey: userWsKey!
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="environment"
defaultValue={environments?.[0]?.slug}
render={({ field: { value, onChange } }) => (
<FormControl label="Environment">
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
defaultValue={environments?.[0]?.slug}
position="popper"
>
{environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field }) => (
<FormControl className="capitalize" label="Secret path">
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="interval"
defaultValue={15}
render={({ field }) => (
<FormControl className="capitalize" label="Rotation Interval (Days)">
<Input
{...field}
min={1}
type="number"
onChange={(evt) => field.onChange(parseInt(evt.target.value, 10))}
/>
</FormControl>
)}
/>
<div className="flex flex-col mt-4 pt-4 mb-2 border-t border-bunker-300/30">
<div>Mapping</div>
<div className="text-bunker-300 text-sm">Select keys for rotated value to get saved</div>
</div>
{Object.keys(outputSchema).map((outputName) => (
<Controller
key={`provider-output-${outputName}`}
control={control}
name={`secrets.${outputName}`}
render={({ field: { value, onChange } }) => (
<FormControl className="uppercase" label={outputName.replaceAll("_", " ")} isRequired>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
>
{!isSecretsLoading &&
secrets
?.filter(
({ _id }) =>
value === _id || !Object.values(selectedSecrets || {}).includes(_id)
)
?.map(({ key, _id }) => (
<SelectItem value={_id} key={_id}>
{key}
</SelectItem>
))}
{isSecretsLoading && (
<SelectItem value="Loading" isDisabled>
<Spinner size="xs" />
</SelectItem>
)}
{!isSecretsLoading && secrets?.length === 0 && (
<SelectItem value="Empty" isDisabled>
No secrets found
</SelectItem>
)}
</Select>
</FormControl>
)}
/>
))}
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Submit
</Button>
<Button onClick={onCancel} colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1 @@
export { SecretRotationPage } from "./SecretRotationPage";