Merge remote-tracking branch 'origin/main' into feat/resource-metadata

This commit is contained in:
Sheen Capadngan
2025-01-07 19:15:33 +08:00
1432 changed files with 33901 additions and 42304 deletions
@@ -0,0 +1,35 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { IPAllowlistSection } from "./components";
export const IPAllowListPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl px-6">
<div className="my-6">
<p className="text-3xl font-semibold text-gray-200">IP Allowlist</p>
<div />
</div>
<ProjectPermissionCan
passThrough={false}
renderGuardBanner
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.IpAllowList}
>
<IPAllowlistSection />
</ProjectPermissionCan>
</div>
</div>
</>
);
};
@@ -0,0 +1,168 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useAddTrustedIp, useGetMyIp, useUpdateTrustedIp } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
ipAddress: z.string(),
comment: z.string()
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["trustedIp"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["trustedIp"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["trustedIp"]>, state?: boolean) => void;
};
export const IPAllowlistModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
const { data, isPending } = useGetMyIp();
const { currentWorkspace } = useWorkspace();
const addTrustedIp = useAddTrustedIp();
const updateTrustedIp = useUpdateTrustedIp();
const {
control,
setValue,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
useEffect(() => {
const trustedIpData = popUp?.trustedIp?.data as {
ipAddress: string;
comment: string;
prefix: number;
};
if (popUp?.trustedIp?.data) {
reset({
ipAddress: `${trustedIpData.ipAddress}${
trustedIpData.prefix !== undefined ? `/${trustedIpData.prefix}` : ""
}`,
comment: trustedIpData.comment
});
} else {
reset({
ipAddress: "",
comment: ""
});
}
}, [popUp?.trustedIp?.data]);
const onIPAllowlistModalSubmit = async ({ ipAddress, comment }: FormData) => {
try {
if (!currentWorkspace?.id) return;
if (popUp?.trustedIp?.data) {
await updateTrustedIp.mutateAsync({
workspaceId: currentWorkspace.id,
trustedIpId: (popUp?.trustedIp?.data as { trustedIpId: string })?.trustedIpId,
ipAddress,
comment,
isActive: true
});
} else {
await addTrustedIp.mutateAsync({
workspaceId: currentWorkspace.id,
ipAddress,
comment,
isActive: true
});
}
createNotification({
text: `Successfully ${popUp?.trustedIp?.data ? "updated" : "added"} trusted IP`,
type: "success"
});
reset();
handlePopUpClose("trustedIp");
} catch {
createNotification({
text: `Failed to ${popUp?.trustedIp?.data ? "update" : "add"} trusted IP`,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.trustedIp?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("trustedIp", isOpen);
reset();
}}
>
<ModalContent title={popUp?.trustedIp?.data ? "Update IP" : "Add IP"}>
<form onSubmit={handleSubmit(onIPAllowlistModalSubmit)}>
<Controller
control={control}
defaultValue=""
name="ipAddress"
render={({ field, fieldState: { error } }) => (
<FormControl
label="IPv4/IPv6 Address / CIDR Notation"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="123.456.789.0" />
</FormControl>
)}
/>
{!isPending && data && (
<Button
colorSchema="secondary"
type="button"
onClick={() => setValue("ipAddress", data)}
className="mb-8"
>
Add current IP address
</Button>
)}
<Controller
control={control}
defaultValue=""
name="comment"
render={({ field, fieldState: { error } }) => (
<FormControl label="Comment" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="My IP address" />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.trustedIp?.data ? "Update" : "Add"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("trustedIp")}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,112 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useSubscription,
useWorkspace
} from "@app/context";
import { useDeleteTrustedIp } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { IPAllowlistModal } from "./IPAllowlistModal";
import { IPAllowlistTable } from "./IPAllowlistTable";
export const IPAllowlistSection = () => {
const { mutateAsync } = useDeleteTrustedIp();
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"trustedIp",
"deleteTrustedIp",
"upgradePlan"
] as const);
const onDeleteTrustedIpSubmit = async (trustedIpId: string) => {
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
workspaceId: currentWorkspace.id,
trustedIpId
});
createNotification({
text: "Successfully deleted IP access range",
type: "success"
});
handlePopUpClose("deleteTrustedIp");
} catch (err) {
console.log(err);
createNotification({
text: "Failed to delete IP access range",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-8 flex items-center">
<h2 className="flex-1 text-xl font-semibold text-white">IP Allowlist</h2>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.IpAllowList}
>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription?.ipAllowlisting) {
handlePopUpOpen("trustedIp");
} else {
handlePopUpOpen("upgradePlan");
}
}}
colorSchema="secondary"
isLoading={false}
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add IP
</Button>
)}
</ProjectPermissionCan>
</div>
<IPAllowlistTable
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<IPAllowlistModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteTrustedIp.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteTrustedIp?.data as { name: string })?.name || " "
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteTrustedIp", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeleteTrustedIpSubmit(
(popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
/>
</div>
);
};
@@ -0,0 +1,166 @@
import { faGlobe, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useSubscription,
useWorkspace
} from "@app/context";
import { useGetTrustedIps } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["upgradePlan"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>,
data?: {
trustedIpId: string;
ipAddress?: string;
comment?: string;
isActive?: boolean;
prefix?: number;
}
) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void;
};
export const IPAllowlistTable = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useGetTrustedIps(currentWorkspace?.id ?? "");
const formatType = (type: string, prefix?: number) => {
return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${
prefix !== undefined ? "CIDR" : ""
}`;
};
return (
<div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="flex-1">IP Address / Range</Th>
<Th className="flex-1">Format</Th>
<Th className="flex-1">Comment</Th>
{/* <Th className="flex-1">Status</Th> */}
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isPending &&
data &&
data?.length > 0 &&
data
.sort((a, b) => a.ipAddress.localeCompare(b.ipAddress))
.map(({ id, ipAddress, comment, type, prefix, isActive }) => {
return (
<Tr key={`ip-access-range-${id}`} className="h-10">
<Td>{`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`}</Td>
<Td>{formatType(type, prefix)}</Td>
<Td>{comment}</Td>
{/* <Td>
<div className="flex items-center">
<FontAwesomeIcon
icon={faCircle}
color="#2ecc71"
/>
<p className="ml-4">Active</p>
</div>
</Td> */}
<Td className="flex items-center">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.IpAllowList}
>
{(isAllowed) => (
<IconButton
className="mr-3 py-2"
onClick={() => {
if (subscription?.ipAllowlisting) {
handlePopUpOpen("trustedIp", {
trustedIpId: id,
ipAddress,
comment,
prefix,
isActive
});
} else {
handlePopUpOpen("upgradePlan");
}
}}
colorSchema="primary"
variant="plain"
ariaLabel="update"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.IpAllowList}
>
{(isAllowed) => (
<IconButton
onClick={() => {
if (subscription?.ipAllowlisting) {
handlePopUpOpen("deleteTrustedIp", {
trustedIpId: id
});
} else {
handlePopUpOpen("upgradePlan");
}
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
{isPending && (
<TableSkeleton innerKey="ip-access-table" columns={4} key="ip-access-ranges" />
)}
{!isPending && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No IP addresses added" icon={faGlobe} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
/>
</div>
);
};
@@ -0,0 +1 @@
export { IPAllowlistSection } from "./IPAllowlistSection";
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { IPAllowListPage } from "./IPAllowlistPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/allowlist"
)({
component: () => IPAllowListPage
});
@@ -0,0 +1,142 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faChevronLeft, faEllipsis, faRefresh, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { OrgPermissionActions, OrgPermissionSubjects, useWorkspace } from "@app/context";
import { useGetIntegration } from "@app/hooks/api";
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
import { IntegrationAuditLogsSection } from "./components/IntegrationAuditLogsSection";
import { IntegrationConnectionSection } from "./components/IntegrationConnectionSection";
import { IntegrationDetailsSection } from "./components/IntegrationDetailsSection";
import { IntegrationSettingsSection } from "./components/IntegrationSettingsSection";
import { integrationSlugNameMapping } from "./IntegrationsDetailsByIDPage.utils";
export const IntegrationDetailsByIDPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const integrationId = useParams({
from: ROUTE_PATHS.SecretManager.IntegrationDetailsByIDPage.id,
select: (el) => el.integrationId
});
const { data: integration } = useGetIntegration(integrationId, {
refetchInterval: 4000
});
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace.id;
const { mutateAsync: syncIntegration } = useSyncIntegration();
return (
<>
<Helmet>
<title>Integration Details | Infisical</title>
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content="Manage your .env files in seconds" />
<meta name="og:description" content={t("integrations.description") as string} />
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{integration ? (
<div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
navigate({
to: "/secret-manager/$projectId/integrations",
params: {
projectId
}
});
}}
className="mb-4"
>
Integrations
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">
{integrationSlugNameMapping[integration.integration]} Integration
</p>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={async () => {
await syncIntegration({
id: integration.id,
lastUsed: integration.lastUsed!,
workspaceId: projectId!
});
}}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faRefresh} />
Manually Sync
</div>
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() => {}}
disabled={!isAllowed}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faTrash} />
Delete Integration
</div>
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex justify-center">
<div className="mr-4 w-96">
<IntegrationDetailsSection integration={integration} />
<IntegrationConnectionSection integration={integration} />
</div>
<div className="space-y-4">
<IntegrationSettingsSection integration={integration} />
<IntegrationAuditLogsSection integration={integration} />
</div>
</div>
</div>
) : (
<div>
<EmptyState title="Error: Unable to fetch integration." className="py-12" />
</div>
)}
</div>
</>
);
};
@@ -0,0 +1,37 @@
export const integrationSlugNameMapping: Record<string, string> = {
"azure-key-vault": "Azure Key Vault",
"aws-parameter-store": "AWS Parameter Store",
"aws-secret-manager": "AWS Secrets Manager",
heroku: "Heroku",
vercel: "Vercel",
netlify: "Netlify",
github: "GitHub",
gitlab: "GitLab",
render: "Render",
"laravel-forge": "Laravel Forge",
railway: "Railway",
flyio: "Fly.io",
circleci: "CircleCI",
databricks: "Databricks",
travisci: "TravisCI",
supabase: "Supabase",
checkly: "Checkly",
qovery: "Qovery",
"terraform-cloud": "Terraform Cloud",
teamcity: "TeamCity",
"hashicorp-vault": "Vault",
"cloudflare-pages": "Cloudflare Pages",
"cloudflare-workers": "Cloudflare Workers",
codefresh: "Codefresh",
"digital-ocean-app-platform": "Digital Ocean App Platform",
bitbucket: "Bitbucket",
"cloud-66": "Cloud 66",
northflank: "Northflank",
windmill: "Windmill",
"gcp-secret-manager": "GCP Secret Manager",
"hasura-cloud": "Hasura Cloud",
rundeck: "Rundeck",
"azure-devops": "Azure DevOps",
"azure-app-configuration": "Azure App Configuration",
"octopus-deploy": "Octopus Deploy"
};
@@ -0,0 +1,85 @@
import { Link } from "@tanstack/react-router";
import { EmptyState } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { EventType } from "@app/hooks/api/auditLogs/enums";
import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
import { LogsSection } from "@app/pages/organization/AuditLogsPage/components/LogsSection";
// Add more events if needed
const INTEGRATION_EVENTS = [EventType.INTEGRATION_SYNCED];
type Props = {
integration: TIntegrationWithEnv;
};
export const IntegrationAuditLogsSection = ({ integration }: Props) => {
const { subscription } = useSubscription();
const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30;
// eslint-disable-next-line no-nested-ternary
return subscription?.auditLogs ? (
<div className="h-full w-full min-w-[51rem] rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<p className="text-lg font-semibold text-gray-200">Integration Logs</p>
<p className="text-xs text-gray-400">
Displaying audit logs from the last {auditLogsRetentionDays} days
</p>
</div>
<LogsSection
refetchInterval={4000}
remappedHeaders={{
Metadata: "Sync Status"
}}
showFilters={false}
presets={{
eventMetadata: { integrationId: integration.id },
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
eventType: INTEGRATION_EVENTS
}}
filterClassName="bg-mineshaft-900 static"
/>
</div>
) : (
<div className="h-full w-full min-w-[51rem] rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4 opacity-60">
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<p className="text-lg font-semibold text-gray-200">Integration Logs</p>
</div>
<EmptyState
className="rounded-lg"
title={
<div>
<p>
Please{" "}
{subscription && subscription.slug !== null ? (
<Link to="/organization/billing" target="_blank" rel="noopener noreferrer">
<a
className="cursor-pointer font-medium text-primary-500 transition-all hover:text-primary-600"
target="_blank"
>
upgrade your subscription
</a>
</Link>
) : (
<a
href="https://infisical.com/scheduledemo"
target="_blank"
rel="noopener noreferrer"
>
<a
className="cursor-pointer font-medium text-primary-500 transition-all hover:text-primary-600"
target="_blank"
>
upgrade your subscription
</a>
</a>
)}
to view integration logs
</p>
</div>
}
/>
</div>
);
};
@@ -0,0 +1,233 @@
import { FormLabel } from "@app/components/v2";
import { CircleCiScope } from "@app/hooks/api/integrationAuth/types";
import { IntegrationMappingBehavior, TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
import { integrationSlugNameMapping } from "../IntegrationsDetailsByIDPage.utils";
type Props = {
integration: TIntegrationWithEnv;
};
export const IntegrationConnectionSection = ({ integration }: Props) => {
const specifcQoveryDetails = () => {
if (integration.integration !== "qovery") return null;
return (
<div className="flex flex-row">
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Organization" />
<div className="text-sm text-mineshaft-300">{integration?.owner || "-"}</div>
</div>
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Project" />
<div className="text-sm text-mineshaft-300">{integration?.targetService || "-"}</div>
</div>
<div className="flex flex-col">
<FormLabel
className="text-sm font-semibold text-mineshaft-300"
label="Target Environment"
/>
<div className="text-sm text-mineshaft-300">{integration?.targetEnvironment || "-"}</div>
</div>
</div>
);
};
const isNotAwsManagerOneToOneDetails = () => {
const isAwsSecretManagerOneToOne =
integration.integration === "aws-secret-manager" &&
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE;
if (isAwsSecretManagerOneToOne) {
return null;
}
const formLabel = () => {
switch (integration.integration) {
case "qovery":
return integration.scope;
case "circleci":
if (integration.scope === CircleCiScope.Context) {
return "Context";
}
return "Project";
case "terraform-cloud":
return "Project";
case "aws-secret-manager":
return "Secret";
case "aws-parameter-store":
case "rundeck":
return "Path";
case "bitbucket":
return "Repository";
case "octopus-deploy":
return "Project";
case "github":
if (["github-env", "github-repo"].includes(integration.scope!)) {
return "Repository";
}
return "Organization";
default:
return "App";
}
};
const contents = () => {
switch (integration.integration) {
case "hashicorp-vault":
return `${integration.app} - path: ${integration.path}`;
case "github":
if (integration.scope === "github-org") {
return `${integration.owner}`;
}
return `${integration.owner}/${integration.app}`;
case "aws-parameter-store":
case "rundeck":
return `${integration.path}`;
default:
return `${integration.app}`;
}
};
return (
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label={formLabel()} />
<div className="text-sm text-mineshaft-300">{contents()}</div>
</div>
);
};
const targetEnvironmentDetails = () => {
if (integration.integration === "bitbucket") {
return (
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Workspace" />
<div className="text-sm text-mineshaft-300">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
);
}
if (integration.integration === "octopus-deploy") {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Space" />
<div className="text-sm text-mineshaft-300">{integration.targetEnvironment}</div>
</div>
);
}
if (
["vercel", "netlify", "railway", "gitlab", "teamcity"].includes(integration.integration) ||
(integration.integration === "github" && integration.scope === "github-env")
) {
return (
<div className="flex flex-col">
<FormLabel
className="text-sm font-semibold text-mineshaft-300"
label="Target Environment"
/>
<div className="text-sm text-mineshaft-300">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
);
}
return null;
};
const generalIntegrationSpecificDetails = () => {
if (integration.integration === "checkly" && integration.targetService) {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Group" />
<div className="text-sm text-mineshaft-300">{integration.targetService}</div>
</div>
);
}
if (integration.integration === "circleci" && integration.owner) {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Organization" />
<div className="text-sm text-mineshaft-300">{integration.owner}</div>
</div>
);
}
if (integration.integration === "terraform-cloud" && integration.targetService) {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Category" />
<div className="text-sm text-mineshaft-300">{integration.targetService}</div>
</div>
);
}
if (integration.integration === "checkly" || integration.integration === "github") {
return (
<div>
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Secret Suffix" />
<div className="text-sm text-mineshaft-300">
{integration?.metadata?.secretSuffix || "-"}
</div>
</div>
);
}
if (integration.integration === "bitbucket" && integration.targetServiceId) {
return (
<div>
<FormLabel
className="text-sm font-semibold text-mineshaft-300"
label="Deployment Environment"
/>
<div className="text-sm text-mineshaft-300">{integration.targetService}</div>
</div>
);
}
return null;
};
return (
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Connection</h3>
</div>
<div className="mt-4">
<FormLabel className="my-2" label="Source" />
<div className="space-y-2 rounded-lg border border-mineshaft-700 bg-mineshaft-800 p-2">
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Environment" />
<div className="text-sm text-mineshaft-300">{integration.environment.name}</div>
</div>
<div className="flex flex-col">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Secret Path" />
<div className="text-sm text-mineshaft-300">{integration.secretPath}</div>
</div>
</div>
<FormLabel className="my-2" label="Destination" />
<div className="space-y-2 rounded-lg border border-mineshaft-700 bg-mineshaft-800 p-2">
<FormLabel className="text-sm font-semibold text-mineshaft-300" label="Platform" />
<div className="text-sm text-mineshaft-300">
{integrationSlugNameMapping?.[integration.integration]}
</div>
{specifcQoveryDetails()}
{isNotAwsManagerOneToOneDetails()}
{targetEnvironmentDetails()}
{generalIntegrationSpecificDetails()}
</div>
</div>
</div>
);
};
@@ -0,0 +1,70 @@
import { faCalendarCheck, faCheckCircle, faCircleXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
import { integrationSlugNameMapping } from "../IntegrationsDetailsByIDPage.utils";
type Props = {
integration: TIntegrationWithEnv;
};
export const IntegrationDetailsSection = ({ integration }: Props) => {
return (
<div>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Integration Details</h3>
</div>
<div className="mt-4">
<div className="space-y-3">
<div>
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">
{integrationSlugNameMapping[integration.integration]}
</p>
</div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Sync Status</p>
<div className="flex items-center">
<p
className={twMerge(
"mr-2 text-sm font-medium",
integration.isSynced ? "text-green-500" : "text-red-500"
)}
>
{integration.isSynced ? "Synced" : "Not Synced"}
</p>
<FontAwesomeIcon
size="sm"
className={twMerge(integration.isSynced ? "text-green-500" : "text-red-500")}
icon={integration.isSynced ? faCheckCircle : faCircleXmark}
/>
</div>
</div>
{integration.lastUsed && (
<div>
<p className="text-sm font-semibold text-mineshaft-300">Latest Successful Sync</p>
<div className="flex items-center gap-2 text-sm text-mineshaft-300">
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")}
<FontAwesomeIcon icon={faCalendarCheck} className="pr-2 pt-0.5 text-sm" />
</div>
</div>
)}
<div>
{!integration.isSynced && integration.syncMessage && (
<>
<p className="text-sm font-semibold text-mineshaft-300">Latest Sync Error</p>
<p className="text-sm text-mineshaft-300">{integration.syncMessage}</p>
</>
)}
</div>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,100 @@
import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
import { OctopusDeployScopeValues } from "./OctopusDeployScopeValues";
type Props = {
integration: TIntegrationWithEnv;
};
type Metadata = NonNullable<TIntegrationWithEnv["metadata"]>;
type MetadataKey = keyof Metadata;
type MetadataValue<K extends MetadataKey> = Metadata[K];
const metadataMappings: Record<keyof NonNullable<TIntegrationWithEnv["metadata"]>, string> = {
githubVisibility: "Github Visibility",
githubVisibilityRepoIds: "Github Visibility Repo Ids",
shouldAutoRedeploy: "Auto Redeploy Target Application When Secrets Change",
secretAWSTag: "Tags For Secrets Stored In AWS",
azureLabel: "Azure Label",
kmsKeyId: "AWS KMS Key ID",
secretSuffix: "Secret Suffix",
secretPrefix: "Secret Prefix",
syncBehavior: "Secrets Sync behavior",
initialSyncBehavior: "Initial Sync Behavior",
mappingBehavior: "Secrets Mapping Behavior",
scope: "Scope",
org: "Organization",
project: "Project",
environment: "Environment",
shouldDisableDelete: "AWS Secret Deletion Disabled",
shouldMaskSecrets: "GitLab Secrets Masking Enabled",
shouldProtectSecrets: "GitLab Secret Protection Enabled",
shouldEnableDelete: "GitHub Secret Deletion Enabled",
octopusDeployScopeValues: "Octopus Deploy Scope Values",
awsIamRole: "AWS IAM Role",
region: "Region",
metadataSyncMode: "Metadata Sync Mode"
} as const;
export const IntegrationSettingsSection = ({ integration }: Props) => {
const renderValue = <K extends MetadataKey>(key: K, value: MetadataValue<K>) => {
if (!value) return null;
if (key === "octopusDeployScopeValues")
return <OctopusDeployScopeValues integration={integration} />;
// If it's a boolean, we render a generic "Yes" or "No" response.
if (typeof value === "boolean") {
return value ? "Yes" : "No";
}
// When the value is an object or array, or array of objects, we need to handle some special cases.
if (typeof value === "object") {
if (key === "secretAWSTag") {
return (value as MetadataValue<"secretAWSTag">)!.map(({ key: tagKey, value: tagValue }) => (
<p key={tagKey} className="text-sm text-gray-200">
{tagKey}={tagValue}
</p>
));
}
if (key === "githubVisibilityRepoIds") {
return (value as string[]).join(", ");
}
}
if (typeof value === "string") {
return value.length ? value : "N/A";
}
if (typeof value === "number") {
return value;
}
return null;
};
if (!integration.metadata || Object.keys(integration.metadata).length === 0) {
return null;
}
// eslint-disable-next-line no-nested-ternary
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<p className="text-lg font-semibold text-gray-200">Integration Settings</p>
</div>
<div className="grid grid-cols-2 gap-4">
{integration.metadata &&
Object.entries(integration.metadata).map(([key, value]) => (
<div key={key} className="flex flex-col">
<p className="text-sm text-gray-400">
{!!value && metadataMappings[key as keyof typeof metadataMappings]}
</p>
<p className="text-sm text-gray-200">{renderValue(key as MetadataKey, value)}</p>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,90 @@
import { FormLabel, Spinner } from "@app/components/v2";
import { useGetIntegrationAuthOctopusDeployScopeValues } from "@app/hooks/api/integrationAuth/queries";
import {
OctopusDeployScope,
TOctopusDeployVariableSetScopeValues
} from "@app/hooks/api/integrationAuth/types";
import { TIntegration, TOctopusDeployScopeValues } from "@app/hooks/api/integrations/types";
type OctopusDeployScopeValuesProps = {
integration: TIntegration;
};
// remove plural since Octopus Deploy can decide whether they want to use singular or plural...
const modifyKey = (key: keyof TOctopusDeployVariableSetScopeValues) => {
switch (key) {
case "Processes":
return "ProcessOwner";
default:
return key.substring(0, key.length - 1);
}
};
export const OctopusDeployScopeValues = ({ integration }: OctopusDeployScopeValuesProps) => {
const hasScopeValues = Boolean(
Object.values(integration.metadata?.octopusDeployScopeValues ?? {}).some(
(values) => values.length > 0
)
);
const { data: scopeValues = {}, isPending } = useGetIntegrationAuthOctopusDeployScopeValues(
{
scope: OctopusDeployScope.Project,
spaceId: integration.targetEnvironmentId!,
resourceId: integration.appId!,
integrationAuthId: integration.integrationAuthId
},
{
enabled: hasScopeValues
}
);
if (!integration.metadata?.octopusDeployScopeValues)
return <span className="text-sm text-mineshaft-400">Not Configured</span>;
if (isPending) return <Spinner size="sm" className="ml-2 mt-2" />;
const scopeValuesMap = new Map(
Object.entries(scopeValues).map(([key, values]) => [
modifyKey(key as keyof TOctopusDeployVariableSetScopeValues),
new Map((values as { Name: string; Id: string }[]).map((value) => [value.Id, value.Name]))
])
);
return (
<>
{Object.entries(integration.metadata.octopusDeployScopeValues).map(([key, values]) => {
if (!values.length) return null;
const getLabel = (scope: string) => {
switch (scope as keyof TOctopusDeployScopeValues) {
case "Role":
return "Target Tags";
case "Machine":
return "Targets";
case "ProcessOwner":
return "Processes";
case "Action":
return "Steps";
default:
return `${scope}s`;
}
};
return (
<div className="mt-4" key={key}>
<FormLabel className="text-sm font-semibold text-mineshaft-200" label={getLabel(key)} />
<div className="text-sm text-mineshaft-300">
{values
.map((value) => scopeValuesMap.get(key)?.get(value))
.map((name) => (
<p key={name}>{name}</p>
))}
</div>
</div>
);
})}
</>
);
};
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { IntegrationDetailsByIDPage } from "./IntegrationsDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/integrations/$integrationId"
)({
component: IntegrationDetailsByIDPage
});
@@ -0,0 +1,236 @@
import { useCallback, useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { ContentLoader } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
useDeleteIntegration,
useDeleteIntegrationAuths,
useGetCloudIntegrations,
useGetWorkspaceAuthorizations,
useGetWorkspaceIntegrations
} from "@app/hooks/api";
import { IntegrationAuth } from "@app/hooks/api/types";
import { CloudIntegrationSection } from "./components/CloudIntegrationSection";
import { FrameworkIntegrationSection } from "./components/FrameworkIntegrationSection";
import { InfrastructureIntegrationSection } from "./components/InfrastructureIntegrationSection/InfrastructureIntegrationSection";
import { IntegrationsSection } from "./components/IntegrationsSection";
import { redirectForProviderAuth } from "./IntegrationsListPage.utils";
enum IntegrationView {
List = "list",
New = "new"
}
const Page = () => {
const { currentWorkspace } = useWorkspace();
const navigate = useNavigate();
const { environments, id: workspaceId } = currentWorkspace;
const [view, setView] = useState<IntegrationView>(IntegrationView.New);
const { data: cloudIntegrations, isPending: isCloudIntegrationsLoading } =
useGetCloudIntegrations();
const {
data: integrationAuths,
isPending: isIntegrationAuthLoading,
isFetching: isIntegrationAuthFetching
} = useGetWorkspaceAuthorizations(
workspaceId,
useCallback((data: IntegrationAuth[]) => {
const groupBy: Record<string, IntegrationAuth> = {};
data.forEach((el) => {
groupBy[el.integration] = el;
});
return groupBy;
}, [])
);
// mutation
const {
data: integrations,
isPending: isIntegrationLoading,
isFetching: isIntegrationFetching,
isFetched: isIntegrationsFetched
} = useGetWorkspaceIntegrations(workspaceId);
const { mutateAsync: deleteIntegration } = useDeleteIntegration();
const {
mutateAsync: deleteIntegrationAuths,
isSuccess: isDeleteIntegrationAuthSuccess,
reset: resetDeleteIntegrationAuths
} = useDeleteIntegrationAuths();
const isIntegrationsAuthorizedEmpty = !Object.keys(integrationAuths || {}).length;
const isIntegrationsEmpty = !integrations?.length;
// summary: this use effect is trigger when all integration auths are removed thus deactivate bot
// details: so on successfully deleting an integration auth, immediately integration list is refeteched
// After the refetch is completed check if its empty. Then set bot active and reset the submit hook for isSuccess to go back to false
useEffect(() => {
if (
isDeleteIntegrationAuthSuccess &&
!isIntegrationFetching &&
!isIntegrationAuthFetching &&
isIntegrationsAuthorizedEmpty &&
isIntegrationsEmpty
) {
resetDeleteIntegrationAuths();
}
}, [
isIntegrationFetching,
isDeleteIntegrationAuthSuccess,
isIntegrationAuthFetching,
isIntegrationsAuthorizedEmpty,
isIntegrationsEmpty
]);
useEffect(() => {
setView(integrations?.length ? IntegrationView.List : IntegrationView.New);
}, [isIntegrationsFetched]);
const handleProviderIntegration = async (provider: string) => {
const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug);
if (!selectedCloudIntegration) return;
try {
redirectForProviderAuth(currentWorkspace.id, navigate, selectedCloudIntegration);
} catch (error) {
console.error(error);
}
};
// function to strat integration for a provider
// confirmation to user passing the bot key for provider to get secret access
const handleProviderIntegrationStart = (provider: string) => {
handleProviderIntegration(provider);
};
const handleIntegrationDelete = async (
integrationId: string,
shouldDeleteIntegrationSecrets: boolean,
cb: () => void
) => {
try {
await deleteIntegration({ id: integrationId, workspaceId, shouldDeleteIntegrationSecrets });
if (cb) cb();
createNotification({
type: "success",
text: "Deleted integration"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to delete integration"
});
}
};
const handleIntegrationAuthRevoke = async (provider: string, cb?: () => void) => {
const integrationAuthForProvider = integrationAuths?.[provider];
if (!integrationAuthForProvider) return;
try {
await deleteIntegrationAuths({
integration: provider,
workspaceId
});
if (cb) cb();
createNotification({
type: "success",
text: "Revoked provider authentication"
});
} catch (err) {
console.error(err);
createNotification({
type: "error",
text: "Failed to revoke provider authentication"
});
}
};
if (isIntegrationLoading || isCloudIntegrationsLoading)
return (
<div className="flex flex-col items-center gap-2">
<ContentLoader text={["Loading integrations..."]} />
</div>
);
return (
<div className="container relative mx-auto max-w-7xl pb-12 text-white">
<div className="relative">
{view === IntegrationView.List ? (
<motion.div
key="view-integrations"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="w-full"
>
<IntegrationsSection
cloudIntegrations={cloudIntegrations}
onAddIntegration={() => setView(IntegrationView.New)}
isLoading={isIntegrationLoading}
integrations={integrations}
environments={environments}
onIntegrationDelete={handleIntegrationDelete}
workspaceId={workspaceId}
/>
</motion.div>
) : (
<motion.div
key="add-integration"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="w-full"
>
<CloudIntegrationSection
onViewActiveIntegrations={
integrations?.length ? () => setView(IntegrationView.List) : undefined
}
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
cloudIntegrations={cloudIntegrations}
integrationAuths={integrationAuths}
onIntegrationStart={handleProviderIntegrationStart}
onIntegrationRevoke={handleIntegrationAuthRevoke}
/>
<FrameworkIntegrationSection />
<InfrastructureIntegrationSection />
</motion.div>
)}
</div>
</div>
);
};
export const IntegrationsListPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("integrations.title") })}</title>
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content="Manage your .env files in seconds" />
<meta name="og:description" content={t("integrations.description") as string} />
</Helmet>
<ProjectPermissionCan
renderGuardBanner
passThrough={false}
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Integrations}
>
<Page />
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,363 @@
import crypto from "crypto";
import { NavigateFn } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import {
decryptAssymmetric,
encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import { localStorageService } from "@app/helpers/localStorage";
import { TCloudIntegration, UserWsKeyPair } from "@app/hooks/api/types";
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 };
};
export const createIntegrationMissingEnvVarsNotification = (
slug: string,
type: "cloud" | "cicd" = "cloud",
hashtag?: string
) =>
createNotification({
type: "error",
text: (
<a
href={`https://infisical.com/docs/integrations/${type}/${slug}${
hashtag ? `#${hashtag}` : ""
}`}
target="_blank"
rel="noreferrer"
className="underline"
>
Click here to view docs
</a>
),
title: "Missing Environment Variables"
});
export const redirectForProviderAuth = (
projectId: string,
navigate: NavigateFn,
integrationOption: TCloudIntegration
) => {
try {
// generate CSRF token for OAuth2 code-token exchange integrations
const state = crypto.randomBytes(16).toString("hex");
localStorage.setItem("latestCSRFToken", state);
localStorageService.setIntegrationProjectId(projectId);
switch (integrationOption.slug) {
case "gcp-secret-manager":
navigate({
to: "/secret-manager/$projectId/integrations/gcp-secret-manager/authorize",
params: {
projectId
}
});
break;
case "azure-key-vault": {
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
return;
}
const link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`;
window.location.assign(link);
break;
}
case "azure-app-configuration": {
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
return;
}
const link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-app-configuration/oauth2/callback&response_mode=query&scope=https://azconfig.io/.default openid offline_access&state=${state}`;
window.location.assign(link);
break;
}
case "aws-parameter-store":
navigate({
to: "/secret-manager/$projectId/integrations/aws-parameter-store/authorize",
params: {
projectId
}
});
break;
case "aws-secret-manager":
navigate({
to: "/secret-manager/$projectId/integrations/aws-secret-manager/authorize",
params: {
projectId
}
});
break;
case "heroku": {
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
return;
}
const link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`;
window.location.assign(link);
break;
}
case "vercel": {
if (!integrationOption.clientSlug) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
return;
}
const link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`;
window.location.assign(link);
break;
}
case "netlify": {
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
return;
}
const link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`;
window.location.assign(link);
break;
}
case "github":
navigate({
to: "/secret-manager/$projectId/integrations/github/auth-mode-selection",
params: {
projectId
}
});
break;
case "gitlab":
navigate({
to: "/secret-manager/$projectId/integrations/gitlab/authorize",
params: {
projectId
}
});
break;
case "render":
navigate({
to: "/secret-manager/$projectId/integrations/render/authorize",
params: {
projectId
}
});
break;
case "flyio":
navigate({
to: "/secret-manager/$projectId/integrations/flyio/authorize",
params: {
projectId
}
});
break;
case "circleci":
navigate({
to: "/secret-manager/$projectId/integrations/circleci/authorize",
params: {
projectId
}
});
break;
case "databricks":
navigate({
to: "/secret-manager/$projectId/integrations/databricks/authorize",
params: {
projectId
}
});
break;
case "laravel-forge":
navigate({
to: "/secret-manager/$projectId/integrations/laravel-forge/authorize",
params: {
projectId
}
});
break;
case "travisci":
navigate({
to: "/secret-manager/$projectId/integrations/travisci/authorize",
params: {
projectId
}
});
break;
case "supabase":
navigate({
to: "/secret-manager/$projectId/integrations/supabase/authorize",
params: {
projectId
}
});
break;
case "checkly":
navigate({
to: "/secret-manager/$projectId/integrations/checkly/authorize",
params: {
projectId
}
});
break;
case "qovery":
navigate({
to: "/secret-manager/$projectId/integrations/qovery/authorize",
params: {
projectId
}
});
break;
case "railway":
navigate({
to: "/secret-manager/$projectId/integrations/railway/authorize",
params: {
projectId
}
});
break;
case "terraform-cloud":
navigate({
to: "/secret-manager/$projectId/integrations/terraform-cloud/authorize",
params: {
projectId
}
});
break;
case "hashicorp-vault":
navigate({
to: "/secret-manager/$projectId/integrations/hashicorp-vault/authorize",
params: {
projectId
}
});
break;
case "cloudflare-pages":
navigate({
to: "/secret-manager/$projectId/integrations/cloudflare-pages/authorize",
params: {
projectId
}
});
break;
case "cloudflare-workers":
navigate({
to: "/secret-manager/$projectId/integrations/cloudflare-workers/authorize",
params: {
projectId
}
});
break;
case "bitbucket": {
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd");
return;
}
const link = `https://bitbucket.org/site/oauth2/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/bitbucket/oauth2/callback&state=${state}`;
window.location.assign(link);
break;
}
case "codefresh":
navigate({
to: "/secret-manager/$projectId/integrations/codefresh/authorize",
params: {
projectId
}
});
break;
case "digital-ocean-app-platform":
navigate({
to: "/secret-manager/$projectId/integrations/digital-ocean-app-platform/authorize",
params: {
projectId
}
});
break;
case "cloud-66":
navigate({
to: "/secret-manager/$projectId/integrations/cloud-66/authorize",
params: {
projectId
}
});
break;
case "northflank":
navigate({
to: "/secret-manager/$projectId/integrations/northflank/authorize",
params: {
projectId
}
});
break;
case "windmill":
navigate({
to: "/secret-manager/$projectId/integrations/windmill/authorize",
params: {
projectId
}
});
break;
case "teamcity":
navigate({
to: "/secret-manager/$projectId/integrations/teamcity/authorize",
params: {
projectId
}
});
break;
case "hasura-cloud":
navigate({
to: "/secret-manager/$projectId/integrations/hasura-cloud/authorize",
params: {
projectId
}
});
break;
case "rundeck":
navigate({
to: "/secret-manager/$projectId/integrations/rundeck/authorize",
params: {
projectId
}
});
break;
case "azure-devops":
navigate({
to: "/secret-manager/$projectId/integrations/azure-devops/authorize",
params: {
projectId
}
});
break;
case "octopus-deploy":
navigate({
to: "/secret-manager/$projectId/integrations/octopus-deploy/authorize",
params: {
projectId
}
});
break;
default:
break;
}
} catch (err) {
console.error(err);
}
};
@@ -0,0 +1,216 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
faCheck,
faChevronLeft,
faMagnifyingGlass,
faSearch,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner";
import { createNotification } from "@app/components/notifications";
import {
Button,
DeleteActionModal,
EmptyState,
Input,
Skeleton,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { IntegrationAuth, TCloudIntegration } from "@app/hooks/api/types";
type Props = {
isLoading?: boolean;
integrationAuths?: Record<string, IntegrationAuth>;
cloudIntegrations?: TCloudIntegration[];
onIntegrationStart: (slug: string) => void;
// cb: handle popUpClose child->parent communication pattern
onIntegrationRevoke: (slug: string, cb: () => void) => void;
onViewActiveIntegrations?: () => void;
};
type TRevokeIntegrationPopUp = { provider: string };
export const CloudIntegrationSection = ({
isLoading,
cloudIntegrations = [],
integrationAuths = {},
onIntegrationStart,
onIntegrationRevoke,
onViewActiveIntegrations
}: Props) => {
const { t } = useTranslation();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteConfirmation"
] as const);
const { permission } = useProjectPermission();
const { currentWorkspace } = useWorkspace();
const isEmpty = !isLoading && !cloudIntegrations?.length;
const sortedCloudIntegrations = useMemo(() => {
const sortedIntegrations = cloudIntegrations.sort((a, b) => a.name.localeCompare(b.name));
if (currentWorkspace?.environments.length === 0) {
return sortedIntegrations.map((integration) => ({ ...integration, isAvailable: false }));
}
return sortedIntegrations;
}, [cloudIntegrations, currentWorkspace?.environments]);
const [search, setSearch] = useState("");
const filteredIntegrations = sortedCloudIntegrations?.filter((cloudIntegration) =>
cloudIntegration.name.toLowerCase().includes(search.toLowerCase().trim())
);
return (
<div>
<div className="px-5">
{currentWorkspace?.environments.length === 0 && (
<NoEnvironmentsBanner projectId={currentWorkspace.id} />
)}
</div>
<div className="m-4 mt-7 flex flex-col items-start justify-between px-2 text-xl">
{onViewActiveIntegrations && (
<Button
variant="link"
onClick={onViewActiveIntegrations}
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
>
Back to Integrations
</Button>
)}
<div className="flex w-full flex-col justify-between gap-4 whitespace-nowrap lg:flex-row lg:items-end lg:gap-8">
<div className="flex-1">
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search cloud integrations..."
containerClassName="flex-1 h-min text-base"
/>
</div>
</div>
<div className="mx-6 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
{isLoading &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton className="h-32" key={`cloud-integration-skeleton-${index + 1}`} />
))}
{!isLoading && filteredIntegrations.length ? (
filteredIntegrations.map((cloudIntegration) => (
<div
onKeyDown={() => null}
role="button"
tabIndex={0}
className={`group relative ${
cloudIntegration.isAvailable
? "cursor-pointer duration-200 hover:bg-mineshaft-700"
: "opacity-50"
} flex h-32 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
onClick={() => {
if (!cloudIntegration.isAvailable) return;
if (
permission.cannot(
ProjectPermissionActions.Create,
ProjectPermissionSub.Integrations
)
) {
createNotification({
type: "error",
text: "You do not have permission to create an integration"
});
return;
}
onIntegrationStart(cloudIntegration.slug);
}}
key={cloudIntegration.slug}
>
<img
src={`/images/integrations/${cloudIntegration.image}`}
height={60}
width={60}
className="mt-auto"
alt="integration logo"
/>
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
{cloudIntegration.name}
</div>
{cloudIntegration.isAvailable &&
Boolean(integrationAuths?.[cloudIntegration.slug]) && (
<div className="absolute right-0 top-0 z-30 h-full">
<div className="relative h-full">
<div className="absolute right-0 top-0 w-24 flex-row items-center overflow-hidden whitespace-nowrap rounded-bl-md rounded-tr-md bg-primary px-2 py-0.5 text-xs text-black opacity-80 transition-all duration-300 group-hover:w-0 group-hover:p-0">
<FontAwesomeIcon icon={faCheck} className="mr-2 text-xs" />
Authorized
</div>
<Tooltip content="Revoke Access">
<div
onKeyDown={() => null}
role="button"
tabIndex={0}
onClick={async (event) => {
event.stopPropagation();
handlePopUpOpen("deleteConfirmation", {
provider: cloudIntegration.slug
});
}}
className="absolute right-0 top-0 flex h-0 w-12 cursor-pointer items-center justify-center overflow-hidden rounded-r-md bg-red text-xs opacity-50 transition-all duration-300 hover:opacity-100 group-hover:h-full"
>
<FontAwesomeIcon icon={faXmark} size="xl" />
</div>
</Tooltip>
</div>
</div>
)}
</div>
))
) : (
<EmptyState
className="col-span-full h-32 w-full rounded-md bg-transparent pt-14"
title="No cloud integrations match search..."
icon={faSearch}
/>
)}
</div>
{isEmpty && (
<div className="mx-6 grid max-w-5xl grid-cols-4 grid-rows-2 gap-4">
{Array.from({ length: 16 }).map((_, index) => (
<div
key={`dummy-cloud-integration-${index + 1}`}
className="h-32 animate-pulse rounded-md border border-mineshaft-600 bg-mineshaft-800"
/>
))}
</div>
)}
<DeleteActionModal
isOpen={popUp.deleteConfirmation.isOpen}
title={`Are you sure want to revoke access ${
(popUp?.deleteConfirmation.data as TRevokeIntegrationPopUp)?.provider || " "
}?`}
subTitle="This will remove all the secret integration of this provider!!!"
onChange={(isOpen) => handlePopUpToggle("deleteConfirmation", isOpen)}
deleteKey={(popUp?.deleteConfirmation?.data as TRevokeIntegrationPopUp)?.provider || ""}
onDeleteApproved={async () => {
onIntegrationRevoke(
(popUp.deleteConfirmation.data as TRevokeIntegrationPopUp)?.provider,
() => handlePopUpClose("deleteConfirmation")
);
}}
/>
</div>
);
};
@@ -0,0 +1 @@
export { CloudIntegrationSection } from "./CloudIntegrationSection";
@@ -0,0 +1,69 @@
import { useTranslation } from "react-i18next";
import { faKeyboard } from "@fortawesome/free-regular-svg-icons";
import { faComputer } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import frameworks from "../json/frameworkIntegrations.json";
export const FrameworkIntegrationSection = () => {
const { t } = useTranslation();
const sortedFrameworks = frameworks.sort((a, b) => a.name.localeCompare(b.name));
return (
<>
<div className="mx-4 mb-4 mt-12 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">{t("integrations.framework-integrations")}</h1>
<p className="text-base text-gray-400">{t("integrations.click-to-setup")}</p>
</div>
<div className="mx-6 mt-4 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
{sortedFrameworks.map((framework) => (
<a
key={`framework-integration-${framework.slug}`}
href={framework.docsLink}
rel="noopener noreferrer"
target="_blank"
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
>
{framework?.image && (
<img
src={`/images/integrations/${framework.image}.png`}
height={60}
width={60}
className="mt-auto"
alt="integration logo"
/>
)}
{framework?.name && (
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
{framework.name}
</div>
)}
</a>
))}
<a
href="https://infisical.com/docs/cli/commands/run"
rel="noopener noreferrer"
target="_blank"
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
>
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faKeyboard} />
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
CLI
</div>
</a>
<a
href="https://infisical.com/docs/sdks/overview"
rel="noopener noreferrer"
target="_blank"
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
>
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faComputer} />
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
SDKs
</div>
</a>
</div>
</>
);
};
@@ -0,0 +1 @@
export { FrameworkIntegrationSection } from "./FrameworkIntegrationSection";
@@ -0,0 +1,45 @@
import integrations from "../json/infrastructureIntegrations.json";
export const InfrastructureIntegrationSection = () => {
const sortedIntegrations = integrations.sort((a, b) => a.name.localeCompare(b.name));
return (
<>
<div className="mx-4 mb-4 mt-12 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">Infrastructure Integrations</h1>
<p className="text-base text-gray-400">
Click on of the integration to read the documentation.
</p>
</div>
<div className="mx-6 grid grid-cols-2 gap-4 lg:grid-cols-3 2xl:grid-cols-4">
{sortedIntegrations.map((integration) => (
<a
key={`framework-integration-${integration.slug}`}
href={integration.docsLink}
rel="noopener noreferrer"
target="_blank"
className="relative flex h-32 w-full cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
>
<div
onKeyDown={() => null}
role="button"
tabIndex={0}
className="group relative flex h-32 w-full cursor-pointer flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
key={integration?.name}
>
<img
src={`/images/integrations/${integration.image}.png`}
height={integration?.name ? 60 : 90}
width={integration?.name ? 60 : 90}
alt="integration logo"
/>
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
{integration?.name && integration.name}
</div>
</div>
</a>
))}
</div>
</>
);
};
@@ -0,0 +1 @@
export { InfrastructureIntegrationSection } from "./InfrastructureIntegrationSection";
@@ -0,0 +1,136 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Checkbox, DeleteActionModal } from "@app/components/v2";
import { usePopUp, useToggle } from "@app/hooks";
import { TCloudIntegration, TIntegration } from "@app/hooks/api/types";
import { IntegrationsTable } from "./components";
type Props = {
environments: Array<{ name: string; slug: string; id: string }>;
integrations?: TIntegration[];
cloudIntegrations?: TCloudIntegration[];
isLoading?: boolean;
onIntegrationDelete: (
integrationId: string,
shouldDeleteIntegrationSecrets: boolean,
cb: () => void
) => Promise<void>;
workspaceId: string;
onAddIntegration: () => void;
};
export const IntegrationsSection = ({
integrations = [],
environments = [],
isLoading,
onIntegrationDelete,
workspaceId,
onAddIntegration,
cloudIntegrations = []
}: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteConfirmation",
"deleteSecretsConfirmation"
] as const);
const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false);
return (
<div className="mx-6 mb-8">
<div className="mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">Integrations</h1>
<p className="text-base text-bunker-300">Manage integrations with third-party services.</p>
</div>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Active Integrations</p>
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={onAddIntegration}
>
Add Integration
</Button>
</div>
<IntegrationsTable
cloudIntegrations={cloudIntegrations}
integrations={integrations}
isLoading={isLoading}
workspaceId={workspaceId}
environments={environments}
onDeleteIntegration={(integration) => {
setShouldDeleteSecrets.off();
handlePopUpOpen("deleteConfirmation", integration);
}}
/>
</div>
<DeleteActionModal
isOpen={popUp.deleteConfirmation.isOpen}
title={`Are you sure want to remove ${
(popUp?.deleteConfirmation.data as TIntegration)?.integration || " "
} integration for ${
(popUp?.deleteConfirmation.data as TIntegration)?.app || "this project"
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteConfirmation", isOpen)}
deleteKey={
((popUp?.deleteConfirmation?.data as TIntegration)?.integration ===
"azure-app-configuration" &&
(popUp?.deleteConfirmation?.data as TIntegration)?.app
?.split("//")[1]
?.split(".")[0]) ||
(popUp?.deleteConfirmation?.data as TIntegration)?.app ||
(popUp?.deleteConfirmation?.data as TIntegration)?.owner ||
(popUp?.deleteConfirmation?.data as TIntegration)?.path ||
(popUp?.deleteConfirmation?.data as TIntegration)?.integration ||
""
}
onDeleteApproved={async () => {
if (shouldDeleteSecrets) {
handlePopUpOpen("deleteSecretsConfirmation");
return;
}
await onIntegrationDelete(
(popUp?.deleteConfirmation.data as TIntegration).id,
false,
() => handlePopUpClose("deleteConfirmation")
);
}}
>
{(popUp?.deleteConfirmation?.data as TIntegration)?.integration === "github" && (
<div className="mt-4">
<Checkbox
id="delete-integration-secrets"
checkIndicatorBg="text-white"
onCheckedChange={() => setShouldDeleteSecrets.toggle()}
>
Delete previously synced secrets from the destination
</Checkbox>
</div>
)}
</DeleteActionModal>
<DeleteActionModal
isOpen={popUp.deleteSecretsConfirmation.isOpen}
title={`Are you sure you also want to delete secrets on ${
(popUp?.deleteConfirmation.data as TIntegration)?.integration
}?`}
subTitle="By confirming, you acknowledge that all secrets managed by this integration will be removed from the destination. This action is irreversible."
onChange={(isOpen) => handlePopUpToggle("deleteSecretsConfirmation", isOpen)}
deleteKey="confirm"
onDeleteApproved={async () => {
await onIntegrationDelete(
(popUp?.deleteConfirmation.data as TIntegration).id,
true,
() => {
handlePopUpClose("deleteSecretsConfirmation");
handlePopUpClose("deleteConfirmation");
}
);
}}
/>
</div>
);
};
@@ -0,0 +1,140 @@
import { FormLabel } from "@app/components/v2";
import { CircleCiScope } from "@app/hooks/api/integrationAuth/types";
import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types";
type Props = {
integration: TIntegration;
};
const FIELD_CLASSNAME =
"truncate rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200";
export const getIntegrationDestination = (integration: TIntegration) =>
(integration.integration === "hashicorp-vault" &&
`${integration.app} - path: ${integration.path}`) ||
(integration.scope === "github-org" && `${integration.owner}`) ||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) ||
(integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) ||
integration.app ||
"-";
export const IntegrationDetails = ({ integration }: Props) => {
return (
<div className="flex flex-col gap-2 p-2">
{integration.integration === "octopus-deploy" && (
<div>
<FormLabel label="Space" />
<div className={FIELD_CLASSNAME}>
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "qovery" && (
<>
<div>
<FormLabel label="Org" />
<div className={FIELD_CLASSNAME}>{integration?.owner || "-"}</div>
</div>
<div>
<FormLabel label="Project" />
<div className={FIELD_CLASSNAME}>{integration?.targetService || "-"}</div>
</div>
<div>
<FormLabel label="Env" />
<div className={FIELD_CLASSNAME}>{integration?.targetEnvironment || "-"}</div>
</div>
</>
)}
{!(
integration.integration === "aws-secret-manager" &&
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
) && (
<div>
<FormLabel
label={
(integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "circleci" &&
(integration.scope === CircleCiScope.Context ? "Context" : "Project")) ||
(integration.integration === "bitbucket" && "Repository") ||
(integration.integration === "octopus-deploy" && "Project") ||
(integration.integration === "aws-secret-manager" && "Secret") ||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
(integration?.integration === "terraform-cloud" && "Project") ||
(integration?.scope === "github-org" && "Organization") ||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
"Repository") ||
"App"
}
/>
<div className={FIELD_CLASSNAME}>{getIntegrationDestination(integration)}</div>
</div>
)}
{(integration.integration === "vercel" ||
integration.integration === "netlify" ||
integration.integration === "railway" ||
integration.integration === "gitlab" ||
integration.integration === "teamcity" ||
(integration.integration === "github" && integration.scope === "github-env")) && (
<div>
<FormLabel label="Target Environment" />
<div className={FIELD_CLASSNAME}>
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "bitbucket" && (
<>
{integration.targetServiceId && (
<div>
<FormLabel label="Environment" />
<div className={FIELD_CLASSNAME}>
{integration.targetService || integration.targetServiceId}
</div>
</div>
)}
<div>
<FormLabel label="Workspace" />
<div className={FIELD_CLASSNAME}>
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
</>
)}
{integration.integration === "checkly" && integration.targetService && (
<div>
<FormLabel label="Group" />
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
</div>
)}
{integration.integration === "circleci" && integration.owner && (
<div>
<FormLabel label="Organization" />
<div className={FIELD_CLASSNAME}>{integration.owner}</div>
</div>
)}
{integration.integration === "terraform-cloud" && integration.targetService && (
<div>
<FormLabel label="Category" />
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
</div>
)}
{(integration.integration === "checkly" || integration.integration === "github") &&
integration?.metadata?.secretSuffix && (
<div>
<FormLabel label="Secret Suffix" />
<div className={FIELD_CLASSNAME}>{integration.metadata.secretSuffix}</div>
</div>
)}
{integration.integration === "github" && integration.metadata?.githubVisibility ? (
<div className="mt-2 text-xs text-mineshaft-200">
{/* eslint-disable-next-line no-nested-ternary */}
{integration.metadata?.githubVisibility === "selected"
? "* Syncing to selected repositories in the organization. "
: integration.metadata?.githubVisibility === "private"
? "* Syncing to all private repositories in the organization"
: "* Syncing to all public and private repositories in the organization"}
</div>
) : undefined}
</div>
);
};
@@ -0,0 +1,194 @@
import { useMemo } from "react";
import {
faCalendarCheck,
faCheck,
faInfoCircle,
faRefresh,
faTrash,
faWarning,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Badge, IconButton, Td, Tooltip, Tr } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { TCloudIntegration } from "@app/hooks/api/integrations/types";
import { TIntegration } from "@app/hooks/api/types";
import { getIntegrationDestination, IntegrationDetails } from "./IntegrationDetails";
type IProps = {
integration: TIntegration;
environment?: { name: string; slug: string; id: string };
onRemoveIntegration: VoidFunction;
onManualSyncIntegration: VoidFunction;
cloudIntegration: TCloudIntegration;
};
export const IntegrationRow = ({
integration,
environment,
onRemoveIntegration,
onManualSyncIntegration,
cloudIntegration
}: IProps) => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const { id, secretPath, syncMessage, isSynced } = integration;
const failureMessage = useMemo(() => {
if (isSynced === false) {
if (syncMessage)
try {
return JSON.stringify(JSON.parse(syncMessage), null, 2);
} catch {
return syncMessage;
}
return "An Unknown Error Occurred.";
}
return null;
}, [isSynced, syncMessage]);
return (
<Tr
onClick={() =>
navigate({
to: "/secret-manager/$projectId/integrations/$integrationId",
params: {
integrationId: integration.id,
projectId: currentWorkspace.id
}
})
}
className={twMerge(
"group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700",
isSynced === false && "bg-red/5 hover:bg-red/10"
)}
key={`integration-${id}`}
>
<Td>
<div className="flex items-center gap-2">
<img
alt={`${cloudIntegration?.name} integration`}
src={`/images/integrations/${cloudIntegration?.image}`}
className="h-5 w-5"
/>
<span className="hidden lg:inline">{cloudIntegration?.name}</span>
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<Tooltip side="top" className="max-w-2xl break-words" content={secretPath}>
<p className="truncate">{secretPath}</p>
</Tooltip>{" "}
</Td>
<Td>{environment?.name ?? "-"}</Td>
<Td className="!min-w-[5rem] max-w-0">
<div className="flex items-center gap-2">
<p className="truncate">{getIntegrationDestination(integration)}</p>
<Tooltip
position="left"
className="min-w-[20rem] max-w-lg"
content={<IntegrationDetails integration={integration} />}
>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</div>
</Td>
<Td>
{" "}
{typeof integration.isSynced !== "boolean" ? (
<Badge variant="primary" key={integration.id}>
Pending Sync
</Badge>
) : (
<Tooltip
position="left"
className="max-w-sm"
content={
<div className="flex flex-col gap-2 py-1">
{integration.lastUsed && (
<div>
<div
className={`mb-2 flex self-start ${!isSynced ? "text-yellow" : "text-green"}`}
>
<FontAwesomeIcon
icon={faCalendarCheck}
className="ml-1 pr-1.5 pt-0.5 text-sm"
/>
<div className="text-xs">Last Synced</div>
</div>
<div className="rounded bg-mineshaft-600 p-2 text-xs">
{format(new Date(integration.lastUsed!), "yyyy-MM-dd, hh:mm aaa")}
</div>
</div>
)}
{failureMessage && (
<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="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div>
</div>
)}
</div>
}
>
<div className="w-min whitespace-nowrap">
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
<div className="flex items-center space-x-1">
<FontAwesomeIcon icon={integration.isSynced ? faCheck : faWarning} />
<div>{integration.isSynced ? "Synced" : "Not Synced"}</div>
</div>
</Badge>
</div>
</Tooltip>
)}
</Td>
<Td>
<div className="flex gap-2 whitespace-nowrap">
<Tooltip className="max-w-sm text-center" content="Manually Sync">
<IconButton
onClick={(e) => {
e.stopPropagation();
onManualSyncIntegration();
}}
ariaLabel="sync"
colorSchema="secondary"
variant="plain"
>
<FontAwesomeIcon icon={faRefresh} />
</IconButton>
</Tooltip>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Integrations}
>
{(isAllowed: boolean) => (
<Tooltip content="Remove Integration">
<IconButton
onClick={(e) => {
e.stopPropagation();
onRemoveIntegration();
}}
ariaLabel="delete"
isDisabled={!isAllowed}
colorSchema="danger"
variant="plain"
>
<FontAwesomeIcon icon={faTrash} className="px-1" />
</IconButton>
</Tooltip>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
};
@@ -0,0 +1,448 @@
import { useEffect, useMemo, useState } from "react";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faArrowDown,
faArrowUp,
faCheck,
faClock,
faFilter,
faMagnifyingGlass,
faPlug,
faSearch,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
import { TCloudIntegration, TIntegration } from "@app/hooks/api/integrations/types";
import { getIntegrationDestination } from "./IntegrationDetails";
import { IntegrationRow } from "./IntegrationRow";
type Props = {
integrations?: TIntegration[];
cloudIntegrations?: TCloudIntegration[];
workspaceId: string;
isLoading?: boolean;
environments: Array<{ name: string; slug: string; id: string }>;
onDeleteIntegration: (integration: TIntegration) => void;
};
enum IntegrationsOrderBy {
App = "app",
Status = "status",
SecretPath = "secretPath",
Environment = "environment",
Destination = "destination"
}
enum IntegrationStatus {
Synced = "synced",
NotSynced = "not-synced",
PendingSync = "pending-sync"
}
type IntegrationFilters = {
environmentIds: string[];
integrations: string[];
status: IntegrationStatus[];
};
const STATUS_ICON_MAP = {
[IntegrationStatus.Synced]: { icon: faCheck, className: "text-green" },
[IntegrationStatus.NotSynced]: { icon: faWarning, className: "text-red" },
[IntegrationStatus.PendingSync]: { icon: faClock, className: "text-yellow" }
};
export const IntegrationsTable = ({
integrations = [],
cloudIntegrations = [],
workspaceId,
environments,
onDeleteIntegration,
isLoading
}: Props) => {
const { mutate: syncIntegration } = useSyncIntegration();
const initialFilters = useMemo(
() => ({
environmentIds: environments.map((env) => env.id),
integrations: [...new Set(integrations.map(({ integration }) => integration))],
status: Object.values(IntegrationStatus)
}),
[environments, integrations]
);
const [filters, setFilters] = useState<IntegrationFilters>(initialFilters);
const cloudIntegrationMap = useMemo(() => {
return new Map(
cloudIntegrations.map((cloudIntegration) => [cloudIntegration.slug, cloudIntegration])
);
}, [cloudIntegrations]);
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<IntegrationsOrderBy>(IntegrationsOrderBy.App, { initPerPage: 20 });
useEffect(() => {
if (integrations?.some((integration) => integration.isSynced === false))
setOrderBy(IntegrationsOrderBy.Status);
}, []);
const environmentMap = new Map(environments.map((env) => [env.id, env]));
const filteredIntegrations = useMemo(
() =>
integrations
.filter((integration) => {
const { secretPath, envId, isSynced } = integration;
if (!filters.status.includes(IntegrationStatus.Synced) && isSynced) return false;
if (!filters.status.includes(IntegrationStatus.NotSynced) && isSynced === false)
return false;
if (
!filters.status.includes(IntegrationStatus.PendingSync) &&
typeof isSynced !== "boolean"
)
return false;
if (!filters.integrations.includes(integration.integration)) return false;
if (!filters.environmentIds.includes(envId)) return false;
return (
integration.integration
.replace("-", " ")
.toLowerCase()
.includes(search.trim().toLowerCase()) ||
secretPath.replace("-", " ").toLowerCase().includes(search.trim().toLowerCase()) ||
getIntegrationDestination(integration)
.toLowerCase()
.includes(search.trim().toLowerCase()) ||
environmentMap
.get(envId)
?.name.replace("-", " ")
.toLowerCase()
.includes(search.trim().toLowerCase())
);
})
.sort((a, b) => {
const [integrationOne, integrationTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case IntegrationsOrderBy.SecretPath:
return integrationOne.secretPath
.toLowerCase()
.localeCompare(integrationTwo.secretPath.toLowerCase());
case IntegrationsOrderBy.Environment:
return (environmentMap.get(integrationOne.envId)?.name ?? "-")
.toLowerCase()
.localeCompare(
(environmentMap.get(integrationTwo.envId)?.name ?? "-").toLowerCase()
);
case IntegrationsOrderBy.Destination:
return getIntegrationDestination(integrationOne)
.toLowerCase()
.localeCompare(getIntegrationDestination(integrationTwo).toLowerCase());
case IntegrationsOrderBy.Status:
if (typeof integrationOne.isSynced !== "boolean") return 1; // Place undefined at the end
if (typeof integrationTwo.isSynced !== "boolean") return -1;
return Number(integrationOne.isSynced) - Number(integrationTwo.isSynced);
case IntegrationsOrderBy.App:
default:
return integrationOne.integration
.toLowerCase()
.localeCompare(integrationTwo.integration.toLowerCase());
}
}),
[integrations, orderDirection, search, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredIntegrations.length,
offset,
setPage
});
const handleSort = (column: IntegrationsOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: IntegrationsOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: IntegrationsOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered =
filters.integrations.length !== initialFilters.integrations.length ||
filters.environmentIds.length !== initialFilters.environmentIds.length ||
filters.status.length !== initialFilters.status.length;
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search integrations..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Environments"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<Tooltip content="Filter Integrations" className="mb-2">
<FontAwesomeIcon icon={faFilter} />
</Tooltip>
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Status</DropdownMenuLabel>
{Object.values(IntegrationStatus).map((status) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
status: prev.status.includes(status)
? prev.status.filter((s) => s !== status)
: [...prev.status, status]
}));
}}
key={status}
icon={
filters.status.includes(status) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon
icon={STATUS_ICON_MAP[status].icon}
className={STATUS_ICON_MAP[status].className}
/>
<span className="capitalize">{status.replace("-", " ")}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuLabel>Integration</DropdownMenuLabel>
{[...new Set(integrations.map(({ integration }) => integration))].map((integration) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
integrations: prev.integrations.includes(integration)
? prev.integrations.filter((i) => i !== integration)
: [...prev.integrations, integration]
}));
}}
key={integration}
icon={
filters.integrations.includes(integration) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${cloudIntegrationMap.get(integration)!.name} integration`}
src={`/images/integrations/${cloudIntegrationMap.get(integration)!.image}`}
className="h-4 w-4"
/>
<span className="capitalize">{cloudIntegrationMap.get(integration)!.name}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuLabel>Environment</DropdownMenuLabel>
{environments.map((env) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
environmentIds: prev.environmentIds.includes(env.id)
? prev.environmentIds.filter((i) => i !== env.id)
: [...prev.environmentIds, env.id]
}));
}}
key={env.id}
icon={
filters.environmentIds.includes(env.id) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<span className="capitalize">{env.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-[25%]">
<div className="flex items-center">
Integration
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.App)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.App)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.App)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Source Path
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.SecretPath)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.SecretPath)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.SecretPath)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Source Environment
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.Environment)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.Environment)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Environment)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Destination
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.Destination)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.Destination)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Destination)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Status
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.Status)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.Status)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Status)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{filteredIntegrations.slice(offset, perPage * page).map((integration) => (
<IntegrationRow
cloudIntegration={cloudIntegrationMap.get(integration.integration)!}
key={`integration-${integration.id}`}
onManualSyncIntegration={() => {
syncIntegration({
workspaceId,
id: integration.id,
lastUsed: integration.lastUsed as string
});
}}
onRemoveIntegration={() => onDeleteIntegration(integration)}
integration={integration}
environment={environmentMap.get(integration.envId)}
/>
))}
</TBody>
</Table>
{Boolean(filteredIntegrations.length) && (
<Pagination
count={filteredIntegrations.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredIntegrations?.length && (
<EmptyState
title={
integrations.length
? "No integrations match search..."
: "This project has no integrations configured"
}
icon={integrations.length ? faSearch : faPlug}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1 @@
export * from "./IntegrationsTable";
@@ -0,0 +1 @@
export { IntegrationsSection } from "./IntegrationsSection";
@@ -0,0 +1,92 @@
[
{
"name": "React",
"slug": "react",
"image": "React",
"docsLink": "https://infisical.com/docs/integrations/frameworks/react"
},
{
"name": "Vue",
"slug": "vue",
"image": "Vue",
"docsLink": "https://infisical.com/docs/integrations/frameworks/vue"
},
{
"name": "Express",
"slug": "express",
"image": "Express",
"docsLink": "https://infisical.com/docs/integrations/frameworks/express"
},
{
"name": "Next.js",
"slug": "nextjs",
"image": "Next.js",
"docsLink": "https://infisical.com/docs/integrations/frameworks/nextjs"
},
{
"name": "SvelteKit",
"slug": "sveltekit",
"image": "SvelteKit",
"docsLink": "https://infisical.com/docs/integrations/frameworks/sveltekit"
},
{
"name": "Django",
"slug": "django",
"image": "Django",
"docsLink": "https://infisical.com/docs/integrations/frameworks/django"
},
{
"name": "NestJS",
"slug": "nestjs",
"image": "NestJS",
"docsLink": "https://infisical.com/docs/integrations/frameworks/nestjs"
},
{
"name": "Nuxt",
"slug": "nuxt",
"image": "Nuxt",
"docsLink": "https://infisical.com/docs/integrations/frameworks/nuxt"
},
{
"name": "Gatsby",
"slug": "gatsby",
"image": "Gatsby",
"docsLink": "https://infisical.com/docs/integrations/frameworks/gatsby"
},
{
"name": "Remix",
"slug": "remix",
"image": "Remix",
"docsLink": "https://infisical.com/docs/integrations/frameworks/remix"
},
{
"name": "Vite",
"slug": "vite",
"image": "Vite",
"docsLink": "https://infisical.com/docs/integrations/frameworks/vite"
},
{
"name": "Fiber",
"slug": "fiber",
"image": "Fiber",
"docsLink": "https://infisical.com/docs/integrations/frameworks/fiber"
},
{
"name": "Flask",
"slug": "flask",
"image": "Flask",
"docsLink": "https://infisical.com/docs/integrations/frameworks/flask"
},
{
"name": "Laravel",
"slug": "laravel",
"image": "Laravel",
"docsLink": "https://infisical.com/docs/integrations/frameworks/laravel"
},
{
"name": "Rails",
"slug": "rails",
"image": "Rails",
"docsLink": "https://infisical.com/docs/integrations/frameworks/rails"
}
]
@@ -0,0 +1,50 @@
[
{
"name": "Docker",
"slug": "docker",
"image": "Docker",
"docsLink": "https://infisical.com/docs/integrations/platforms/docker"
},
{
"name": "Docker Compose",
"slug": "docker-compose",
"image": "Docker Compose",
"docsLink": "https://infisical.com/docs/integrations/platforms/docker-compose"
},
{
"name": "Kubernetes",
"slug": "kubernetes",
"image": "Kubernetes",
"docsLink": "https://infisical.com/docs/integrations/platforms/kubernetes"
},
{
"name": "Terraform",
"slug": "terraform",
"image": "Terraform",
"docsLink": "https://infisical.com/docs/integrations/frameworks/terraform"
},
{
"name": "Jenkins",
"slug": "jenkins",
"image": "Jenkins",
"docsLink": "https://infisical.com/docs/integrations/cicd/jenkins"
},
{
"name": "Infisical Agent",
"slug": "agent",
"image": "Agent",
"docsLink": "https://infisical.com/docs/integrations/platforms/infisical-agent"
},
{
"name": "Amazon ECS",
"slug": "ecs",
"image": "ECS",
"docsLink": "https://infisical.com/docs/integrations/platforms/ecs-with-agent"
},
{
"name": "Ansible",
"slug": "ansible",
"image": "Ansible",
"docsLink": "https://infisical.com/docs/integrations/platforms/ansible"
}
]
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { IntegrationsListPage } from "./IntegrationsListPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/integrations/"
)({
component: IntegrationsListPage
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,318 @@
import { ClipboardEvent, useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { getKeyValue } from "@app/helpers/parseEnvVar";
import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
import { SecretType } from "@app/hooks/api/types";
const typeSchema = z
.object({
key: z.string().trim().min(1, "Key is required"),
value: z.string().optional(),
environments: z.object({ name: z.string(), slug: z.string() }).array(),
tags: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).optional()
})
.refine((data) => data.key !== undefined, {
message: "Please enter secret name"
});
type TFormSchema = z.infer<typeof typeSchema>;
type Props = {
secretPath?: string;
// modal props
onClose: () => void;
};
export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
const {
register,
handleSubmit,
control,
reset,
setValue,
watch,
formState: { isSubmitting, errors }
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const canReadTags = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
const workspaceId = currentWorkspace?.id || "";
const environments = currentWorkspace?.environments || [];
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const { mutateAsync: createFolder } = useCreateFolder();
const { data: projectTags, isPending: isTagsLoading } = useGetWsTags(
canReadTags ? workspaceId : ""
);
const secretKeyInputRef = useRef<HTMLInputElement>(null);
const { ref: setSecretKeyHookRef, ...secretKeyRegisterRest } = register("key");
const secretKey = watch("key");
const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: TFormSchema) => {
const promises = selectedEnv.map(async (env) => {
const environment = env.slug;
// create folder if not existing
if (secretPath !== "/") {
// /hello/world -> [hello","world"]
const pathSegment = secretPath.split("/").filter(Boolean);
const parentPath = `/${pathSegment.slice(0, -1).join("/")}`;
const folderName = pathSegment.at(-1);
const canCreateFolder = permission.can(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.SecretFolders, {
environment: env.slug,
secretPath: parentPath
})
);
if (folderName && parentPath && canCreateFolder) {
await createFolder({
projectId: workspaceId,
path: parentPath,
environment,
name: folderName
});
}
}
// TODO: add back ability to overwrite - need to fetch secrets by key to check for conflicts as previous method broke with pagination
return {
...(await createSecretV3({
environment,
workspaceId,
secretPath,
secretKey: key,
secretValue: value || "",
secretComment: "",
type: SecretType.Shared,
tagIds: tags?.map((el) => el.value)
})),
environment
};
});
const results = await Promise.allSettled(promises);
const forApprovalEnvs = results
.map((result) =>
result.status === "fulfilled" && "approval" in result.value
? result.value.environment
: undefined
)
.filter(Boolean) as string[];
const updatedEnvs = results
.map((result) =>
result.status === "fulfilled" && !("approval" in result.value)
? result.value.environment
: undefined
)
.filter(Boolean) as string[];
if (forApprovalEnvs.length) {
createNotification({
type: "info",
text: `Change request submitted for ${
forApprovalEnvs.length > 1 ? "environments" : "environment"
}: ${forApprovalEnvs.join(", ")}`
});
}
if (updatedEnvs.length) {
createNotification({
type: "success",
text: `Secrets created in ${
updatedEnvs.length > 1 ? "environments" : "environment"
}: ${updatedEnvs.join(", ")}`
});
}
if (!updatedEnvs.length && !forApprovalEnvs.length) {
createNotification({
type: "error",
text: "Failed to create secrets"
});
} else {
onClose();
reset();
}
};
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
const delimitters = [":", "="];
const pastedContent = e.clipboardData.getData("text");
const { key, value } = getKeyValue(pastedContent, delimitters);
const isWholeKeyHighlighted =
secretKeyInputRef.current &&
secretKeyInputRef.current.selectionStart === 0 &&
secretKeyInputRef.current.selectionEnd === secretKeyInputRef.current.value.length;
if (!secretKey || isWholeKeyHighlighted) {
e.preventDefault();
setValue("key", key);
if (value) {
setValue("value", value);
}
}
};
const createWsTag = useCreateWsTag();
const slugSchema = z.string().trim().toLowerCase().min(1);
const createNewTag = async (slug: string) => {
// TODO: Replace with slugSchema generic
try {
const parsedSlug = slugSchema.parse(slug);
await createWsTag.mutateAsync({
workspaceID: workspaceId,
tagSlug: parsedSlug,
tagColor: ""
});
} catch {
createNotification({
type: "error",
text: "Failed to create new tag"
});
}
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} noValidate>
<FormControl
label="Key"
isRequired
isError={Boolean(errors?.key)}
errorText={errors?.key?.message}
>
<Input
{...secretKeyRegisterRest}
ref={(e) => {
setSecretKeyHookRef(e);
// @ts-expect-error this is for multiple ref single component
secretKeyInputRef.current = e;
}}
placeholder="Type your secret name"
onPaste={handlePaste}
autoCapitalization={currentWorkspace?.autoCapitalization}
/>
</FormControl>
<Controller
control={control}
name="value"
render={({ field }) => (
<FormControl
label="Value"
isError={Boolean(errors?.value)}
errorText={errors?.value?.message}
>
<InfisicalSecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="tags"
render={({ field }) => (
<FormControl
label="Tags"
isError={Boolean(errors?.value)}
errorText={errors?.value?.message}
helperText={
!canReadTags ? (
<div className="flex items-center space-x-2">
<FontAwesomeIcon icon={faTriangleExclamation} className="text-yellow-400" />
<span>You do not have permission to read tags.</span>
</div>
) : (
""
)
}
>
<CreatableSelect
isMulti
className="w-full"
placeholder="Select tags to assign to secret..."
isValidNewOption={(v) => slugSchema.safeParse(v).success}
name="tagIds"
isDisabled={!canReadTags}
isLoading={isTagsLoading && canReadTags}
options={projectTags?.map((el) => ({ label: el.slug, value: el.id }))}
value={field.value}
onChange={field.onChange}
onCreateOption={createNewTag}
/>
</FormControl>
)}
/>
<Controller
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl label="Environments" isError={Boolean(error)} errorText={error?.message}>
<FilterableSelect
isMulti
options={environments.filter((environment) =>
permission.can(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, {
environment: environment.slug,
secretPath,
secretName: "*",
secretTags: ["*"]
})
)
)}
value={value}
onChange={onChange}
placeholder="Select environments to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
/>
</FormControl>
)}
name="environments"
/>
<div className="mt-7 flex items-center">
<Button
isDisabled={isSubmitting}
isLoading={isSubmitting}
key="layout-create-project-submit"
className="mr-4"
type="submit"
>
Create Secret
</Button>
<Button
key="layout-cancel-create-project"
onClick={onClose}
variant="plain"
colorSchema="secondary"
>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1 @@
export { CreateSecretForm } from "./CreateSecretForm";
@@ -0,0 +1,53 @@
import { faFolderOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
type Props = {
secretPath: string;
onResetSearch: (path: string) => void;
};
export const FolderBreadCrumbs = ({ secretPath = "/", onResetSearch }: Props) => {
const navigate = useNavigate({
from: "/secret-manager/$projectId/overview"
});
const onFolderCrumbClick = (index: number) => {
const newSecPath = `/${secretPath.split("/").filter(Boolean).slice(0, index).join("/")}`;
if (secretPath === newSecPath) return;
navigate({
search: (prev) => ({ ...prev, secretPath: newSecPath })
}).then(() => onResetSearch(newSecPath));
};
return (
<div className="flex items-center space-x-2">
<div
className="breadcrumb relative z-20 border-solid border-mineshaft-600 bg-mineshaft-800 py-1 pl-5 pr-2 text-sm hover:bg-mineshaft-600"
onClick={() => onFolderCrumbClick(0)}
onKeyDown={() => null}
role="button"
tabIndex={0}
>
<FontAwesomeIcon icon={faFolderOpen} className="text-primary-700" />
</div>
{(secretPath || "")
.split("/")
.filter(Boolean)
.map((path, index, arr) => (
<div
key={`secret-path-${index + 1}`}
className={`breadcrumb relative z-20 ${
index + 1 === arr.length ? "cursor-default" : "cursor-pointer"
} border-solid border-mineshaft-600 py-1 pl-5 pr-2 text-sm text-mineshaft-200`}
onClick={() => onFolderCrumbClick(index + 1)}
onKeyDown={() => null}
role="button"
tabIndex={0}
>
{path}
</div>
))}
</div>
);
};
@@ -0,0 +1 @@
export { FolderBreadCrumbs } from "./FolderBreadCrumbs";
@@ -0,0 +1,50 @@
import { faCheck, faFingerprint, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Td, Tr } from "@app/components/v2";
type Props = {
dynamicSecretName: string;
environments: { name: string; slug: string }[];
isDynamicSecretInEnv: (name: string, env: string) => boolean;
};
export const SecretOverviewDynamicSecretRow = ({
dynamicSecretName,
environments = [],
isDynamicSecretInEnv
}: Props) => {
return (
<Tr isHoverable isSelectable className="group">
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
<div className="flex items-center space-x-5 border-r border-mineshaft-600 px-5 py-2.5">
<div className="text-yellow-700">
<FontAwesomeIcon icon={faFingerprint} />
</div>
<div>{dynamicSecretName}</div>
</div>
</Td>
{environments.map(({ slug }, i) => {
const isPresent = isDynamicSecretInEnv(dynamicSecretName, slug);
return (
<Td
key={`sec-overview-${slug}-${i + 1}-folder`}
className={twMerge(
"border-r border-mineshaft-600 py-3 group-hover:bg-mineshaft-700",
isPresent ? "text-green-600" : "text-red-600"
)}
>
<div className="flex justify-center">
<FontAwesomeIcon
// eslint-disable-next-line no-nested-ternary
icon={isPresent ? faCheck : faXmark}
/>
</div>
</Td>
);
})}
</Tr>
);
};
@@ -0,0 +1 @@
export { SecretOverviewDynamicSecretRow } from "./SecretOverviewDynamicSecretRow";
@@ -0,0 +1,84 @@
import { faCheck, faFolder, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Checkbox, IconButton, Td, Tr } from "@app/components/v2";
type Props = {
folderName: string;
environments: { name: string; slug: string }[];
isFolderPresentInEnv: (name: string, env: string) => boolean;
onClick: (path: string) => void;
isSelected: boolean;
onToggleFolderSelect: (folderName: string) => void;
onToggleFolderEdit: (name: string) => void;
};
export const SecretOverviewFolderRow = ({
folderName,
environments = [],
isFolderPresentInEnv,
isSelected,
onToggleFolderSelect,
onToggleFolderEdit,
onClick
}: Props) => {
return (
<Tr isHoverable isSelectable className="group" onClick={() => onClick(folderName)}>
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
<div className="flex items-center space-x-5 border-r border-mineshaft-600 px-5 py-2.5">
<div className="text-yellow-700">
<Checkbox
id={`checkbox-${folderName}`}
isChecked={isSelected}
onCheckedChange={() => {
onToggleFolderSelect(folderName);
}}
onClick={(e) => {
e.stopPropagation();
}}
className={twMerge("hidden group-hover:flex", isSelected && "flex")}
/>
<FontAwesomeIcon
className={twMerge("block group-hover:hidden", isSelected && "hidden")}
icon={faFolder}
/>
</div>
<div>{folderName}</div>
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
onToggleFolderEdit(folderName);
e.stopPropagation();
}}
>
<FontAwesomeIcon icon={faPencil} size="sm" />
</IconButton>
</div>
</Td>
{environments.map(({ slug }, i) => {
const isPresent = isFolderPresentInEnv(folderName, slug);
return (
<Td
key={`sec-overview-${slug}-${i + 1}-folder`}
className={twMerge(
"border-r border-mineshaft-600 py-3 group-hover:bg-mineshaft-700",
isPresent ? "text-green-600" : "text-red-600"
)}
>
<div className="flex justify-center">
<FontAwesomeIcon
// eslint-disable-next-line no-nested-ternary
icon={isPresent ? faCheck : faXmark}
/>
</div>
</Td>
);
})}
</Tr>
);
};
@@ -0,0 +1 @@
export { SecretOverviewFolderRow } from "./SecretOverviewFolderRow";
@@ -0,0 +1,295 @@
import { useCallback, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import {
faCheck,
faCopy,
faProjectDiagram,
faTrash,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
hasSecretReference,
SecretReferenceTree
} from "@app/components/secrets/SecretReferenceDetails";
import {
DeleteActionModal,
IconButton,
Modal,
ModalContent,
ModalTrigger,
Tooltip
} from "@app/components/v2";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks";
import { SecretType } from "@app/hooks/api/types";
type Props = {
defaultValue?: string | null;
secretName: string;
secretId?: string;
isOverride?: boolean;
isCreatable?: boolean;
isVisible?: boolean;
isImportedSecret: boolean;
environment: string;
secretPath: string;
onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
onSecretUpdate: (
env: string,
key: string,
value: string,
type?: SecretType,
secretId?: string
) => Promise<void>;
onSecretDelete: (env: string, key: string, secretId?: string) => Promise<void>;
};
export const SecretEditRow = ({
defaultValue,
isCreatable,
isOverride,
isImportedSecret,
onSecretUpdate,
secretName,
onSecretCreate,
onSecretDelete,
environment,
secretPath,
isVisible,
secretId
}: Props) => {
const {
handleSubmit,
control,
reset,
getValues,
formState: { isDirty, isSubmitting }
} = useForm({
values: {
value: defaultValue || null
}
});
const [isDeleting, setIsDeleting] = useToggle();
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const toggleModal = useCallback(() => {
setIsModalOpen((prev) => !prev);
}, []);
const handleFormReset = () => {
reset();
};
const handleCopySecretToClipboard = async () => {
const { value } = getValues();
if (value) {
try {
await window.navigator.clipboard.writeText(value);
createNotification({ type: "success", text: "Copied secret to clipboard" });
} catch (error) {
console.log(error);
createNotification({ type: "error", text: "Failed to copy secret to clipboard" });
}
}
};
const handleFormSubmit = async ({ value }: { value?: string | null }) => {
if ((value || value === "") && secretName) {
if (isCreatable) {
await onSecretCreate(environment, secretName, value);
} else {
await onSecretUpdate(
environment,
secretName,
value,
isOverride ? SecretType.Personal : SecretType.Shared,
secretId
);
}
}
reset({ value });
};
const handleDeleteSecret = useCallback(async () => {
setIsDeleting.on();
setIsModalOpen(false);
try {
await onSecretDelete(environment, secretName, secretId);
reset({ value: null });
} finally {
setIsDeleting.off();
}
}, [onSecretDelete, environment, secretName, secretId, reset, setIsDeleting]);
return (
<div className="group flex w-full cursor-text items-center space-x-2">
<DeleteActionModal
isOpen={isModalOpen}
onClose={toggleModal}
title="Do you want to delete the selected secret?"
deleteKey="delete"
onDeleteApproved={handleDeleteSecret}
/>
<div className="flex-grow border-r border-r-mineshaft-600 pl-1 pr-2">
<Controller
disabled={isImportedSecret && !defaultValue}
control={control}
name="value"
render={({ field }) => (
<InfisicalSecretInput
{...field}
isReadOnly={isImportedSecret}
value={field.value as string}
key="secret-input"
isVisible={isVisible}
secretPath={secretPath}
environment={environment}
isImport={isImportedSecret}
/>
)}
/>
</div>
<div
className={twMerge(
"flex w-24 justify-center space-x-3 pl-2 transition-all",
isImportedSecret && "pointer-events-none opacity-0"
)}
>
{isDirty ? (
<>
<ProjectPermissionCan
I={isCreatable ? ProjectPermissionActions.Create : ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: ["*"]
})}
>
{(isAllowed) => (
<div>
<Tooltip content="save">
<IconButton
variant="plain"
ariaLabel="submit-value"
className="h-full"
isDisabled={isSubmitting || !isAllowed}
onClick={handleSubmit(handleFormSubmit)}
>
<FontAwesomeIcon icon={faCheck} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
<div>
<Tooltip content="cancel">
<IconButton
variant="plain"
colorSchema="danger"
ariaLabel="reset-value"
className="h-full"
onClick={handleFormReset}
isDisabled={isSubmitting}
>
<FontAwesomeIcon icon={faXmark} className="hover:text-red" />
</IconButton>
</Tooltip>
</div>
</>
) : (
<>
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Copy Secret">
<IconButton
ariaLabel="copy-value"
onClick={handleCopySecretToClipboard}
variant="plain"
className="h-full"
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</Tooltip>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Secrets}
>
{(isAllowed) => (
<div className="opacity-0 group-hover:opacity-100">
<Modal>
<ModalTrigger asChild>
<div className="opacity-0 group-hover:opacity-100">
<Tooltip
content={
hasSecretReference(defaultValue || "")
? "Secret Reference Tree"
: "Secret does not contain references"
}
>
<IconButton
variant="plain"
ariaLabel="reference-tree"
className="h-full"
isDisabled={!hasSecretReference(defaultValue || "") || !isAllowed}
>
<FontAwesomeIcon icon={faProjectDiagram} />
</IconButton>
</Tooltip>
</div>
</ModalTrigger>
<ModalContent
title="Secret Reference Details"
subTitle="Visual breakdown of secrets referenced by this secret."
onOpenAutoFocus={(e) => e.preventDefault()} // prevents secret input from displaying value on open
>
<SecretReferenceTree
secretPath={secretPath}
environment={environment}
secretKey={secretName}
/>
</ModalContent>
</Modal>
</div>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: ["*"]
})}
>
{(isAllowed) => (
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Delete">
<IconButton
variant="plain"
ariaLabel="delete-value"
className="h-full"
onClick={toggleModal}
isDisabled={isDeleting || !isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</>
)}
</div>
</div>
);
};
@@ -0,0 +1,51 @@
import { faCircle } from "@fortawesome/free-regular-svg-icons";
import { faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Td, Tooltip, Tr } from "@app/components/v2";
type Props = {
environments: { name: string; slug: string }[];
count: number;
};
export const SecretNoAccessOverviewTableRow = ({ environments = [], count }: Props) => {
return (
<>
{Array.from(Array(count)).map((_, j) => (
<Tr key={`no-access-secret-overview-${j + 1}`} isHoverable isSelectable className="group">
<Td className="sticky left-0 z-10 bg-mineshaft-800 bg-clip-padding px-0 py-0 group-hover:bg-mineshaft-700">
<div className="h-full w-full border-r border-mineshaft-600 px-5 py-2.5">
<Tooltip
asChild
content="You do not have permission to view this secret"
className="max-w-sm"
>
<div className="flex items-center space-x-5">
<div className="text-bunker-300">
<FontAwesomeIcon className="block" icon={faLock} />
</div>
<div className="blur-sm">NO ACCESS</div>
</div>
</Tooltip>
</div>
</Td>
{environments.map(({ slug }, i) => {
return (
<Td
key={`sec-overview-${slug}-${i + 1}-value`}
className="px-0 py-0 group-hover:bg-mineshaft-700"
>
<div className="h-full w-full border-r border-mineshaft-600 px-5 py-[0.85rem]">
<div className="flex justify-center">
<FontAwesomeIcon icon={faCircle} />
</div>
</div>
</Td>
);
})}
</Tr>
))}
</>
);
};
@@ -0,0 +1,251 @@
import { faCircle } from "@fortawesome/free-regular-svg-icons";
import {
faAngleDown,
faCheck,
faEye,
faEyeSlash,
faFileImport,
faKey,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { SecretEditRow } from "./SecretEditRow";
import SecretRenameRow from "./SecretRenameRow";
type Props = {
secretKey: string;
secretPath: string;
environments: { name: string; slug: string }[];
isSelected: boolean;
onToggleSecretSelect: (key: string) => void;
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
onSecretUpdate: (
env: string,
key: string,
value: string,
type?: SecretType,
secretId?: string
) => Promise<void>;
onSecretDelete: (env: string, key: string, secretId?: string) => Promise<void>;
isImportedSecretPresentInEnv: (env: string, secretName: string) => boolean;
getImportedSecretByKey: (
env: string,
secretName: string
) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined;
scrollOffset: number;
};
export const SecretOverviewTableRow = ({
secretKey,
environments = [],
secretPath,
getSecretByKey,
onSecretUpdate,
onSecretCreate,
onSecretDelete,
isImportedSecretPresentInEnv,
getImportedSecretByKey,
scrollOffset,
onToggleSecretSelect,
isSelected
}: Props) => {
const [isFormExpanded, setIsFormExpanded] = useToggle();
const totalCols = environments.length + 1; // secret key row
const [isSecretVisible, setIsSecretVisible] = useToggle();
return (
<>
<Tr isHoverable isSelectable onClick={() => setIsFormExpanded.toggle()} className="group">
<Td
className={`sticky left-0 z-10 bg-mineshaft-800 bg-clip-padding px-0 py-0 group-hover:bg-mineshaft-700 ${
isFormExpanded && "border-t-2 border-mineshaft-500"
}`}
>
<div className="h-full w-full border-r border-mineshaft-600 px-5 py-2.5">
<div className="flex items-center space-x-5">
<div className="text-bunker-300">
<Checkbox
id={`checkbox-${secretKey}`}
isChecked={isSelected}
onCheckedChange={() => {
onToggleSecretSelect(secretKey);
}}
onClick={(e) => {
e.stopPropagation();
}}
className={twMerge("hidden group-hover:flex", isSelected && "flex")}
/>
<FontAwesomeIcon
className={twMerge("block group-hover:hidden", isSelected && "hidden")}
icon={isFormExpanded ? faAngleDown : faKey}
/>
</div>
<div title={secretKey}>{secretKey}</div>
</div>
</div>
</Td>
{environments.map(({ slug }, i) => {
const secret = getSecretByKey(slug, secretKey);
const isSecretImported = isImportedSecretPresentInEnv(slug, secretKey);
const isSecretPresent = Boolean(secret);
const isSecretEmpty = secret?.value === "";
return (
<Td
key={`sec-overview-${slug}-${i + 1}-value`}
className={twMerge(
"px-0 py-0 group-hover:bg-mineshaft-700",
isFormExpanded && "border-t-2 border-mineshaft-500",
(isSecretPresent && !isSecretEmpty) || isSecretImported ? "text-green-600" : "",
isSecretPresent && isSecretEmpty && !isSecretImported ? "text-yellow" : "",
!isSecretPresent && !isSecretEmpty && !isSecretImported ? "text-red-600" : ""
)}
>
<div className="h-full w-full border-r border-mineshaft-600 px-5 py-[0.85rem]">
<div className="flex justify-center">
{!isSecretEmpty && (
<Tooltip
center
content={
// eslint-disable-next-line no-nested-ternary
isSecretPresent
? "Present secret"
: isSecretImported
? "Imported secret"
: "Missing secret"
}
>
<FontAwesomeIcon
// eslint-disable-next-line no-nested-ternary
icon={isSecretPresent ? faCheck : isSecretImported ? faFileImport : faXmark}
/>
</Tooltip>
)}
{isSecretEmpty && (
<Tooltip content="Empty value">
<FontAwesomeIcon icon={faCircle} />
</Tooltip>
)}
</div>
</div>
</Td>
);
})}
</Tr>
{isFormExpanded && (
<Tr>
<Td
colSpan={totalCols}
className={`bg-bunker-600 px-0 py-0 ${
isFormExpanded && "border-b-2 border-mineshaft-500"
}`}
>
<div
className="ml-2 p-2"
style={{
marginLeft: scrollOffset,
width: "calc(100vw - 300px)", // 300px accounts for sidebar and margin
maxWidth: "calc(1536px - 50px)" // tw container max width minus padding for uw displays
}}
>
<SecretRenameRow
secretKey={secretKey}
environments={environments}
secretPath={secretPath}
getSecretByKey={getSecretByKey}
/>
<TableContainer>
<table className="secret-table">
<thead>
<tr className="h-10 border-b-2 border-mineshaft-600">
<th
style={{ padding: "0.5rem 1rem" }}
className="min-table-row min-w-[11rem]"
>
Environment
</th>
<th style={{ padding: "0.5rem 1rem" }} className="border-none">
Value
</th>
<div className="absolute right-0 top-0 ml-auto mr-1 mt-1 w-min">
<Button
variant="outline_bg"
className="p-1"
leftIcon={<FontAwesomeIcon icon={isSecretVisible ? faEyeSlash : faEye} />}
onClick={() => setIsSecretVisible.toggle()}
>
{isSecretVisible ? "Hide Values" : "Reveal Values"}
</Button>
</div>
</tr>
</thead>
<tbody className="border-t-2 border-mineshaft-600">
{environments.map(({ name, slug }) => {
const secret = getSecretByKey(slug, secretKey);
const isCreatable = !secret;
const isImportedSecret = isImportedSecretPresentInEnv(slug, secretKey);
const importedSecret = getImportedSecretByKey(slug, secretKey);
return (
<tr
key={`secret-expanded-${slug}-${secretKey}`}
className="hover:bg-mineshaft-700"
>
<td
className="flex h-full items-center"
style={{ padding: "0.25rem 1rem" }}
>
<div title={name} className="flex h-8 w-[8rem] items-center space-x-2">
<span className="truncate">{name}</span>
{isImportedSecret && (
<Tooltip
content={`Imported secret from the '${importedSecret?.environmentInfo?.name}' environment`}
>
<FontAwesomeIcon icon={faFileImport} />
</Tooltip>
)}
</div>
</td>
<td className="col-span-2 h-8 w-full">
<SecretEditRow
secretPath={secretPath}
isVisible={isSecretVisible}
secretName={secretKey}
defaultValue={
secret?.valueOverride ||
secret?.value ||
importedSecret?.secret?.value
}
secretId={secret?.id}
isOverride={Boolean(secret?.valueOverride)}
isImportedSecret={isImportedSecret}
isCreatable={isCreatable}
onSecretDelete={onSecretDelete}
onSecretCreate={onSecretCreate}
onSecretUpdate={onSecretUpdate}
environment={slug}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</TableContainer>
</div>
</Td>
</Tr>
)}
</>
);
};
@@ -0,0 +1,259 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCheck, faClose, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { IconButton, Input, Spinner, Tooltip } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { useToggle } from "@app/hooks";
import { useUpdateSecretV3 } from "@app/hooks/api";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types";
enum SecretActionType {
Created = "created",
Modified = "modified",
Deleted = "deleted"
}
type Props = {
secretKey: string;
secretPath: string;
environments: { name: string; slug: string }[];
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
};
export const formSchema = z.object({
key: z.string().trim().min(1, { message: "Secret key is required" })
});
type TFormSchema = z.infer<typeof formSchema>;
function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }: Props) {
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const secrets = environments.map((env) => getSecretByKey(env.slug, secretKey));
const isReadOnly = environments.some((env) => {
const environment = env.slug;
const secretDetails = getSecretByKey(environment, secretKey);
const secretPermissionSubject = subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: (secretDetails?.tags || []).map((i) => i.slug)
});
const isSecretInEnvReadOnly =
permission.can(ProjectPermissionActions.Read, secretPermissionSubject) &&
permission.cannot(ProjectPermissionActions.Edit, secretPermissionSubject);
if (isSecretInEnvReadOnly) {
return true;
}
return false;
});
const isOverriden = secrets.some(
(secret) =>
secret?.overrideAction === SecretActionType.Created ||
secret?.overrideAction === SecretActionType.Modified
);
const workspaceId = currentWorkspace?.id || "";
const [isSecNameCopied, setIsSecNameCopied] = useToggle(false);
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
const {
handleSubmit,
control,
reset,
trigger,
getValues,
formState: { isDirty, isSubmitting, errors }
} = useForm<TFormSchema>({
defaultValues: { key: secretKey },
values: { key: secretKey },
resolver: zodResolver(formSchema)
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isSecNameCopied) {
timer = setTimeout(() => setIsSecNameCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isSecNameCopied]);
const handleFormSubmit = async (data: TFormSchema) => {
if (!data.key) {
createNotification({
type: "error",
text: "Secret name cannot be empty"
});
return;
}
const promises = secrets
.filter((secret) => !!secret)
.map((secret) => {
if (!secret) return null;
return updateSecretV3({
environment: secret?.env,
workspaceId,
secretPath,
secretKey: secret.key,
secretValue: secret.value || "",
type: SecretType.Shared,
tagIds: secret.tags?.map((tag) => tag.id),
secretComment: secret.comment,
secretReminderRepeatDays: secret.reminderRepeatDays,
secretReminderNote: secret.reminderNote,
skipMultilineEncoding: secret.skipMultilineEncoding,
newSecretName: data.key
});
});
await Promise.all(promises)
.then(() => {
createNotification({
type: "success",
text: "Successfully renamed the secret"
});
})
.catch(() => {
createNotification({
type: "error",
text: "Error renaming the secret"
});
});
};
const copyTokenToClipboard = () => {
const [key] = getValues(["key"]);
navigator.clipboard.writeText(key as string);
setIsSecNameCopied.on();
};
return (
<form
onSubmit={handleSubmit(handleFormSubmit)}
className="secret-table relative mb-2 flex w-full flex-row items-center justify-between overflow-hidden rounded-lg border border-solid border-mineshaft-700 bg-mineshaft-800 font-inter"
>
<div className="flex h-11 flex-1 flex-shrink-0 items-center">
<span className="flex h-full min-w-[11rem] items-center justify-start border-r-2 border-mineshaft-600 px-4">
Key
</span>
<Controller
name="key"
control={control}
render={({ field, fieldState: { error } }) => (
<Input
autoComplete="off"
isReadOnly={isReadOnly}
autoCapitalization={currentWorkspace?.autoCapitalization}
variant="plain"
isDisabled={isOverriden}
placeholder={error?.message}
onKeyUp={() => trigger("key")}
isError={Boolean(error)}
{...field}
className="w-full px-2 placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
/>
)}
/>
</div>
{isReadOnly || isOverriden ? (
<span className="mr-5 rounded-md bg-mineshaft-500 px-2">Read Only</span>
) : (
<div className="group flex w-20 items-center justify-center border-l border-mineshaft-500 py-1">
<AnimatePresence mode="wait">
{!isDirty ? (
<motion.div
key="options"
className="flex flex-shrink-0 items-center space-x-4 px-3"
initial={{ x: 0, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 10, opacity: 0 }}
>
<Tooltip content="Copy secret name">
<IconButton
ariaLabel="copy-value"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isSecNameCopied ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</motion.div>
) : (
<motion.div
key="options-save"
className="flex flex-shrink-0 items-center space-x-4 px-3"
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -10, opacity: 0 }}
>
<Tooltip content={errors.key ? errors.key.message : "Save"}>
<IconButton
ariaLabel="more"
variant="plain"
type="submit"
size="md"
className={twMerge(
"p-0 text-primary opacity-0 group-hover:opacity-100",
isDirty && "opacity-100"
)}
isDisabled={isSubmitting || Boolean(errors.key)}
>
{isSubmitting ? (
<Spinner className="m-0 h-4 w-4 p-0" />
) : (
<FontAwesomeIcon
icon={faCheck}
size="lg"
className={twMerge("text-primary", errors.key && "text-mineshaft-400")}
/>
)}
</IconButton>
</Tooltip>
<Tooltip content="Cancel">
<IconButton
ariaLabel="more"
variant="plain"
size="md"
className={twMerge(
"p-0 opacity-0 group-hover:opacity-100",
isDirty && "opacity-100"
)}
onClick={() => reset()}
isDisabled={isSubmitting}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
</Tooltip>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</form>
);
}
export default SecretRenameRow;
@@ -0,0 +1,2 @@
export { SecretNoAccessOverviewTableRow } from "./SecretNoAccessOverviewTableRow";
export { SecretOverviewTableRow } from "./SecretOverviewTableRow";
@@ -0,0 +1,113 @@
import { useState } from "react";
import { faCircleXmark, faFolderTree, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Combobox, Transition } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { IconButton, Tooltip } from "@app/components/v2";
import { QuickSearchModal, QuickSearchModalProps } from "./components";
type ModalProps = Omit<
QuickSearchModalProps,
"isOpen" | "onClose" | "onOpenChange" | "initialValue"
> & {
value: string;
onChange: (search: string) => void;
className?: string;
};
export const SecretSearchInput = ({
value,
onChange,
className,
isSingleEnv,
...props
}: ModalProps) => {
const [isOpen, setIsOpen] = useState(false);
const hasSearch = Boolean(value.trim());
return (
<div className={twMerge("relative w-80", className)}>
<Combobox
// keeps combobox from internally controlling state, hacky use of combobox
value={undefined}
>
{({ activeIndex }) => (
<>
<div className="flex w-full items-center whitespace-nowrap">
<Tooltip content="Search Options">
<Combobox.Button className="button user-select-none relative inline-flex h-[2.42rem] cursor-pointer items-center justify-center rounded-md rounded-r-none border border-mineshaft-600 bg-mineshaft-600 p-3 font-inter text-sm font-medium text-bunker-200 transition-all duration-100 hover:border-primary-400/50 hover:bg-primary/[0.1] hover:text-bunker-100">
<FontAwesomeIcon
icon={faSearch}
size="sm"
className={hasSearch ? "text-primary" : ""}
aria-hidden="true"
/>
</Combobox.Button>
</Tooltip>
<div className="relative inline-flex w-full items-center rounded-md rounded-l-none border border-mineshaft-500 bg-bunker-800 font-inter text-gray-400">
<Combobox.Input
onKeyDown={(e) => {
if (activeIndex === 0 && e.key === "Enter") setIsOpen(true);
}}
autoComplete="off"
className="input text-md h-[2.3rem] w-full rounded-md rounded-l-none bg-mineshaft-800 py-[0.375rem] pl-2.5 pr-8 text-gray-400 placeholder-mineshaft-50 placeholder-opacity-50 outline-none duration-200 placeholder:text-sm hover:ring-bunker-400/60 focus:bg-mineshaft-700/80 focus:ring-1 focus:ring-primary-400/50"
placeholder="Search by secret/folder name..."
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{hasSearch && (
<IconButton
isRounded
variant="plain"
onClick={() => onChange("")}
className="absolute right-2 text-primary"
ariaLabel="Clear search"
>
<FontAwesomeIcon icon={faCircleXmark} />
</IconButton>
)}
</div>
</div>
<Transition
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Combobox.Options className="absolute z-30 mt-2 w-full min-w-[220px] overflow-y-auto rounded-md border border-mineshaft-600 bg-mineshaft-900 text-bunker-300 shadow focus:outline-none">
<Combobox.Option
onClick={() => setIsOpen(true)}
value={value}
className={({ active }) =>
`flex w-full cursor-pointer items-start rounded-sm px-4 py-2 font-inter text-sm text-mineshaft-200 outline-none hover:bg-mineshaft-400 ${
active ? "bg-mineshaft-500" : ""
}`
}
>
<FontAwesomeIcon icon={faFolderTree} className="mr-2 mt-1 text-yellow-700" />
{value.trim()
? `Search for "${
value.length > 10 ? `${value.substring(0, 10)}...` : value
}" in all folders`
: "Search in all folders"}
</Combobox.Option>
</Combobox.Options>
</Transition>
</>
)}
</Combobox>
<QuickSearchModal
isSingleEnv={isSingleEnv}
isOpen={isOpen}
onOpenChange={setIsOpen}
initialValue={value}
onClose={() => {
setIsOpen(false);
onChange("");
}}
{...props}
/>
</div>
);
};
@@ -0,0 +1,61 @@
import { faChevronRight, faFingerprint, faFolder } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { Td, Tooltip, Tr } from "@app/components/v2";
import { reverseTruncate } from "@app/helpers/reverseTruncate";
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
type Props = {
dynamicSecretGroup: TDashboardProjectSecretsQuickSearch["dynamicSecrets"][string];
onClose: () => void;
};
export const QuickSearchDynamicSecretItem = ({
dynamicSecretGroup,
onClose
}: Props) => {
const navigate = useNavigate({
from: "/secret-manager/$projectId/overview"
});
const [groupDynamicSecret] = dynamicSecretGroup;
const handleNavigate = () => {
navigate({
search: (prev) => ({
...prev,
secretPath: groupDynamicSecret.path,
search: groupDynamicSecret.name
})
});
onClose();
};
return (
<Tr
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
onClick={handleNavigate}
>
<Td className="w-full">
<div className="inline-flex max-w-[20rem] flex-col">
<span className="truncate">
<FontAwesomeIcon className="mr-2 self-center text-yellow-700" icon={faFingerprint} />
{groupDynamicSecret.name}
</span>
<span className="text-xs text-mineshaft-400">
<FontAwesomeIcon size="xs" className="mr-0.5 text-yellow-700" icon={faFolder} />{" "}
<Tooltip className="max-w-7xl" content={groupDynamicSecret.path}>
<span>{reverseTruncate(groupDynamicSecret.path)}</span>
</Tooltip>
</span>
</div>
</Td>
<Td />
<Td>
<FontAwesomeIcon icon={faChevronRight} />
</Td>
</Tr>
);
};
@@ -0,0 +1,45 @@
import { faChevronRight, faFolder } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { Td, Tooltip, Tr } from "@app/components/v2";
import { reverseTruncate } from "@app/helpers/reverseTruncate";
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
type Props = {
folderGroup: TDashboardProjectSecretsQuickSearch["folders"][string];
onClose: () => void;
};
export const QuickSearchFolderItem = ({ folderGroup, onClose }: Props) => {
const navigate = useNavigate({
from: "/secret-manager/$projectId/overview"
});
const [groupFolder] = folderGroup;
const handleNavigate = () => {
navigate({
search: (prev) => ({ ...prev, secretPath: groupFolder.path })
});
onClose();
};
return (
<Tr
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
onClick={handleNavigate}
>
<Td className="w-full whitespace-nowrap">
<FontAwesomeIcon className="text-yellow-700" icon={faFolder} />
<Tooltip content={groupFolder.path} className="max-w-7xl">
<div className="ml-2 inline-block">{reverseTruncate(groupFolder.path)}</div>
</Tooltip>
</Td>
<Td />
<Td>
<FontAwesomeIcon icon={faChevronRight} />
</Td>
</Tr>
);
};
@@ -0,0 +1,288 @@
import { useState } from "react";
import {
faCheckCircle,
faChevronLeft,
faFilter,
faFingerprint,
faFolder,
faKey,
faMagnifyingGlass
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
EmptyState,
IconButton,
Input,
Modal,
ModalContent,
Spinner,
Table,
TableContainer,
Tooltip
} from "@app/components/v2";
import { useDebounce } from "@app/hooks";
import { useGetProjectSecretsQuickSearch } from "@app/hooks/api/dashboard";
import { WsTag } from "@app/hooks/api/tags/types";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
import { RowType } from "@app/pages/secret-manager/SecretDashboardPage/SecretMainPage.types";
import { QuickSearchDynamicSecretItem } from "./QuickSearchDynamicSecretItem";
import { QuickSearchFolderItem } from "./QuickSearchFolderItem";
import { QuickSearchSecretItem } from "./QuickSearchSecretItem";
export type QuickSearchModalProps = {
environments: WorkspaceEnv[];
projectId: string;
tags?: WsTag[];
isSingleEnv?: boolean;
initialValue: string;
onClose: () => void;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ResourceType = RowType.Secret | RowType.DynamicSecret | RowType.Folder;
const Content = ({
environments,
projectId,
onClose,
tags,
initialValue = "",
isSingleEnv
}: Omit<QuickSearchModalProps, "isOpen" | "onOpenChange">) => {
const [search, setSearch] = useState(initialValue);
const [debouncedSearch] = useDebounce(search);
const [filterTags, setFilterTags] = useState<Record<string, boolean>>({});
const [showFilter, setShowFilter] = useState<Record<ResourceType, boolean>>({
[RowType.Secret]: true,
[RowType.Folder]: true,
[RowType.DynamicSecret]: true
});
const isEnabled = Boolean(search.trim()) || Boolean(Object.values(filterTags).length);
const { data, isPending } = useGetProjectSecretsQuickSearch(
{
secretPath: "/",
environments: environments.map((env) => env.slug),
projectId,
search: debouncedSearch,
tags: filterTags
},
{ enabled: isEnabled }
);
const { folders = {}, secrets = {}, dynamicSecrets = {} } = data ?? {};
const isEmpty =
(!showFilter[RowType.Folder] || Object.values(folders).length === 0) &&
(!showFilter[RowType.Secret] || Object.values(secrets).length === 0) &&
(!showFilter[RowType.DynamicSecret] || Object.values(dynamicSecrets).length === 0);
const handleToggleTag = (tag: string) => {
setFilterTags((prev) => {
const updated = { ...prev };
if (prev[tag]) delete updated[tag];
else updated[tag] = true;
return updated;
});
};
const handleToggleShowType = (type: ResourceType) => {
setShowFilter((prev) => ({
...prev,
[type]: !prev[type]
}));
};
return (
<div className="min-h-[14.6rem]">
<div className="flex gap-2">
<Input
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by secret, folder or tag name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={
<FontAwesomeIcon icon={faMagnifyingGlass} className={search ? "text-primary" : ""} />
}
/>
<DropdownMenu>
<Tooltip content="Search Filters">
<DropdownMenuTrigger asChild>
<IconButton
variant="outline_bg"
ariaLabel="Filter secrets by tag(s)"
className={twMerge(
"transition-all",
(Object.keys(filterTags).length ||
Object.values(showFilter).some((show) => !show)) &&
"border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="end" className="p-0">
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleShowType(RowType.Folder);
}}
icon={showFilter[RowType.Folder] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
<span>Folders</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleShowType(RowType.DynamicSecret);
}}
icon={showFilter[RowType.DynamicSecret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFingerprint} className="text-yellow-700" />
<span>Dynamic Secrets</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleShowType(RowType.Secret);
}}
icon={showFilter[RowType.Secret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faKey} className="text-bunker-300" />
<span>Secrets</span>
</div>
</DropdownMenuItem>
{tags && tags.length > 0 && (
<DropdownSubMenu>
<DropdownSubMenuTrigger
iconPos="left"
icon={<FontAwesomeIcon icon={faChevronLeft} size="sm" />}
>
Tags
</DropdownSubMenuTrigger>
<DropdownSubMenuContent
collisionPadding={{ right: Infinity }} // forces dropdown to left
className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-r-none"
>
<DropdownMenuLabel>Filter Secrets by Tag(s)</DropdownMenuLabel>
{tags.map(({ id, slug, color }) => (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
handleToggleTag(slug);
}}
key={id}
icon={filterTags[slug] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: color || "#bec2c8" }}
/>
{slug}
</div>
</DropdownMenuItem>
))}
</DropdownSubMenuContent>
</DropdownSubMenu>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-4 max-h-[19rem] min-h-[19rem] overflow-auto">
{/* eslint-disable-next-line no-nested-ternary */}
{isEnabled ? (
// eslint-disable-next-line no-nested-ternary
isPending ? (
<Spinner size="lg" className="mx-auto mt-24 text-mineshaft-900" />
) : isEmpty ? (
<EmptyState
className="mt-24"
title="No results match search."
icon={faMagnifyingGlass}
/>
) : (
<TableContainer className="thin-scrollbar h-full overflow-y-auto">
<Table>
{showFilter[RowType.Folder] &&
Object.entries(folders).map(([key, folderGroup]) => (
<QuickSearchFolderItem onClose={onClose} folderGroup={folderGroup} key={key} />
))}
{showFilter[RowType.DynamicSecret] &&
Object.entries(dynamicSecrets).map(([key, dynamicSecretGroup]) => (
<QuickSearchDynamicSecretItem
onClose={onClose}
dynamicSecretGroup={dynamicSecretGroup}
key={key}
/>
))}
{showFilter[RowType.Secret] &&
Object.entries(secrets).map(([key, secretGroup]) => (
<QuickSearchSecretItem
search={debouncedSearch}
tags={Object.keys(filterTags)}
isSingleEnv={isSingleEnv}
environments={environments}
onClose={onClose}
secretGroup={secretGroup}
key={key}
/>
))}
</Table>
</TableContainer>
)
) : (
<EmptyState
className="mt-24"
title="Start typing to begin search..."
icon={faMagnifyingGlass}
/>
)}
</div>
</div>
);
};
export const QuickSearchModal = ({
isOpen,
isSingleEnv,
onOpenChange,
...props
}: QuickSearchModalProps) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title={`Search All Folders${isSingleEnv ? " In Environment" : ""}`}
subTitle={`Search the ${
isSingleEnv ? "current environment" : "entire project"
} to quickly reference secrets and navigate deeply.`}
>
<Content isSingleEnv={isSingleEnv} {...props} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,215 @@
import {
faCheck,
faChevronRight,
faCopy,
faEye,
faFolder,
faKey,
faTags
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { reverseTruncate } from "@app/helpers/reverseTruncate";
import { useTimedReset } from "@app/hooks";
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
type Props = {
environments: WorkspaceEnv[];
secretGroup: TDashboardProjectSecretsQuickSearch["secrets"][string];
onClose: () => void;
isSingleEnv?: boolean;
tags: string[];
search: string;
};
export const QuickSearchSecretItem = ({
secretGroup,
environments,
onClose,
tags,
isSingleEnv,
search
}: Props) => {
const navigate = useNavigate({ from: "/secret-manager/$projectId/overview" });
const envSlugMap = new Map(environments.map((env) => [env.slug, env]));
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false
});
const [groupSecret] = secretGroup;
const handleNavigate = () => {
navigate({
search: (prev) => ({
...prev,
secretPath: groupSecret.path,
search: groupSecret.key,
tags: tags.length ? tags.join(",") : undefined
})
});
onClose();
};
const handleCopy = (value: string, env: string) => {
navigator.clipboard.writeText(value);
createNotification({
type: "info",
title: isSingleEnv ? "Secret value copied." : `Secret value copied from ${env}.`,
text: ""
});
setIsUrlCopied(true);
};
const secretGroupTags = secretGroup.flatMap((secret) => secret.tags);
const tagMatch =
search.trim() &&
secretGroupTags?.find((tag) => tag && tag.slug.toLowerCase().includes(search.toLowerCase()));
return (
<Tr
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
onClick={handleNavigate}
>
<Td className="w-full">
<div className="inline-flex max-w-[20rem] flex-col">
<span className="truncate">
<FontAwesomeIcon className="mr-2 self-center text-bunker-300" icon={faKey} />
{groupSecret.key}
</span>
<span className="text-xs text-mineshaft-400">
<FontAwesomeIcon size="xs" className="mr-0.5 text-yellow-700" icon={faFolder} />{" "}
<Tooltip className="max-w-7xl" content={groupSecret.path}>
<span>{reverseTruncate(groupSecret.path ?? "")}</span>
</Tooltip>
</span>
</div>
</Td>
<Td>
<div className="flex w-full items-center justify-end gap-4">
{tagMatch && (
<Badge variant="primary" className="flex items-center gap-1 whitespace-nowrap">
<FontAwesomeIcon size="xs" icon={faTags} />
{tagMatch.slug}
</Badge>
)}
{isSingleEnv ? (
<IconButton
size="md"
variant="plain"
colorSchema="secondary"
ariaLabel="Copy secret value"
onClick={(e) => {
e.stopPropagation();
const el = envSlugMap.get(groupSecret.env)?.name;
if (el) {
handleCopy(groupSecret.value!, el);
}
}}
>
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
</IconButton>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
size="md"
variant="plain"
colorSchema="secondary"
ariaLabel="Copy secret value"
>
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Copy Value From...</DropdownMenuLabel>
{secretGroup.map((secret) => (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
const el = envSlugMap.get(secret.env)?.name;
if (el) {
handleCopy(secret.value!, el);
}
}}
key={secret.id}
>
<p className="text-sm">{envSlugMap.get(secret.env)?.name}</p>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
size="md"
variant="plain"
colorSchema="secondary"
ariaLabel="View secret value"
>
<FontAwesomeIcon icon={faEye} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Hover to Reveal...</DropdownMenuLabel>
{secretGroup.map((secret) => (
<DropdownMenuItem
className="group"
onClick={(e) => {
e.stopPropagation();
const el = envSlugMap.get(secret.env)?.name;
if (el) {
handleCopy(secret.value!, el);
}
}}
key={secret.id}
>
<Tooltip side="left" sideOffset={18} content="Click to copy to clipboard">
<div>
{!isSingleEnv && (
<span className="text-xs text-mineshaft-400">
{envSlugMap.get(secret.env)?.name}
</span>
)}
<p
className={twMerge(
"hidden w-[12rem] max-w-[12rem] truncate text-sm group-hover:block",
!secret.value && "text-mineshaft-400"
)}
>
{secret.value || "EMPTY"}
</p>
<p className="w-[12rem] text-sm group-hover:hidden">
***************************
</p>
</div>
</Tooltip>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
<Td>
<FontAwesomeIcon icon={faChevronRight} />
</Td>
</Tr>
);
};
@@ -0,0 +1,4 @@
export * from "./QuickSearchDynamicSecretItem";
export * from "./QuickSearchFolderItem";
export * from "./QuickSearchModal";
export * from "./QuickSearchSecretItem";
@@ -0,0 +1 @@
export { SecretSearchInput } from "./SecretSearchInput";
@@ -0,0 +1,87 @@
import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tooltip } from "@app/components/v2";
type Props = {
folderCount?: number;
importCount?: number;
secretCount?: number;
dynamicSecretCount?: number;
};
export const SecretTableResourceCount = ({
folderCount = 0,
dynamicSecretCount = 0,
secretCount = 0,
importCount = 0
}: Props) => {
return (
<div className="flex items-center gap-2 divide-x divide-mineshaft-500 text-sm text-mineshaft-400">
{importCount > 0 && (
<Tooltip
className="max-w-sm"
content={
<p className="whitespace-nowrap text-center">
Total import count{" "}
<span className="text-center text-mineshaft-400">(matching filters)</span>
</p>
}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFileImport} className="text-green-700" />
<span>{importCount}</span>
</div>
</Tooltip>
)}
{folderCount > 0 && (
<Tooltip
className="max-w-sm"
content={
<p className="whitespace-nowrap text-center">
Total folder count{" "}
<span className="text-center text-mineshaft-400">(matching filters)</span>
</p>
}
>
<div className="flex items-center gap-2 pl-2">
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
<span>{folderCount}</span>
</div>
</Tooltip>
)}
{dynamicSecretCount > 0 && (
<Tooltip
className="max-w-sm"
content={
<p className="whitespace-nowrap text-center">
Total dynamic secret count{" "}
<span className="text-center text-mineshaft-400">(matching filters)</span>
</p>
}
>
<div className="flex items-center gap-2 pl-2">
<FontAwesomeIcon icon={faFingerprint} className="text-yellow-700" />
<span>{dynamicSecretCount}</span>
</div>
</Tooltip>
)}
{secretCount > 0 && (
<Tooltip
className="max-w-sm"
content={
<p className="whitespace-nowrap text-center">
Total secret count{" "}
<span className="text-center text-mineshaft-400">(matching filters)</span>
</p>
}
>
<div className="flex items-center gap-2 pl-2">
<FontAwesomeIcon icon={faKey} className="text-bunker-300" />
<span>{secretCount}</span>
</div>
</Tooltip>
)}
</div>
);
};
@@ -0,0 +1 @@
export { SecretTableResourceCount } from "./SecretTableResourceCount";
@@ -0,0 +1,198 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, Modal, ModalContent, Spinner } from "@app/components/v2";
import { useProjectPermission, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetWorkspaceById, useMigrateProjectToV3, workspaceKeys } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types";
enum ProjectUpgradeStatus {
InProgress = "IN_PROGRESS",
// Completed -> Will be null if completed. So a completed status is not needed
Failed = "FAILED"
}
const formSchema = z.object({
isCLIChecked: z.literal(true),
isOperatorChecked: z.literal(true),
shouldCloseOpenApprovals: z.literal(true)
});
export const SecretV2MigrationSection = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["migrationInfo"] as const);
const { currentWorkspace } = useWorkspace();
const queryClient = useQueryClient();
const { data: workspaceDetails, refetch } = useGetWorkspaceById(
// if v3 no need to fetch
currentWorkspace?.version === ProjectVersion.V3 ? "" : currentWorkspace?.id || "",
{
refetchInterval:
currentWorkspace?.upgradeStatus === ProjectUpgradeStatus.InProgress ? 2000 : false
}
);
const { membership } = useProjectPermission();
const migrateProjectToV3 = useMigrateProjectToV3();
const { handleSubmit, control, reset } = useForm({ resolver: zodResolver(formSchema) });
useEffect(() => {
if (!popUp.migrationInfo.isOpen) {
reset();
}
}, [popUp.migrationInfo.isOpen]);
const isProjectUpgraded = workspaceDetails?.version === ProjectVersion.V3;
useEffect(() => {
if (isProjectUpgraded && migrateProjectToV3.data) {
createNotification({ type: "success", text: "Project upgrade completed successfully" });
migrateProjectToV3.reset();
queryClient.invalidateQueries({
queryKey: workspaceKeys.getAllUserWorkspace(ProjectType.SecretManager)
});
}
}, [isProjectUpgraded, Boolean(migrateProjectToV3.data)]);
if (isProjectUpgraded || currentWorkspace?.version === ProjectVersion.V3) return null;
const isUpgrading = workspaceDetails?.upgradeStatus === ProjectUpgradeStatus.InProgress;
const didProjectUpgradeFailed = workspaceDetails?.upgradeStatus === ProjectUpgradeStatus.Failed;
const handleMigrationSecretV2 = async () => {
try {
handlePopUpToggle("migrationInfo");
await migrateProjectToV3.mutateAsync({ workspaceId: currentWorkspace?.id || "" });
refetch();
createNotification({
text: "Project upgrade started",
type: "success"
});
} catch {
createNotification({
text: "Failed to upgrade project",
type: "error"
});
}
};
const isAdmin = membership?.roles.includes(ProjectMembershipRole.Admin);
return (
<div className="mt-4 flex max-w-2xl flex-col rounded-lg border border-primary/50 bg-primary/10 px-6 py-5">
{isUpgrading && (
<div className="absolute left-0 top-0 z-50 flex h-screen w-screen items-center justify-center bg-bunker-500 bg-opacity-80">
<Spinner size="lg" className="text-primary" />
<div className="ml-4 flex flex-col space-y-1">
<div className="text-3xl font-medium">Please wait</div>
<span className="inline-block">Upgrading secrets engine...</span>
</div>
</div>
)}
<div className="mb-4 flex items-start gap-2">
<FontAwesomeIcon icon={faWarning} size="xl" className="mt-1 text-primary" />
<p className="text-xl font-semibold">Upgrade secrets engine version</p>
</div>
<p className="mx-1 mb-4 leading-7 text-mineshaft-100">
Your existing workflows to fetch secrets will continue to work. However, viewing secrets on
the UI requires you to upgrade your project&apos;s secrets engine version.
</p>
<p className="mx-1 mb-4 leading-7 text-mineshaft-100">
Upgrading is free and enables the use of Infisical&apos;s new secrets engine, which is 10x
faster and allows you to encrypt secrets with your own KMS provider.
</p>
<p className="mx-1 mb-6 leading-7 text-mineshaft-100">
The upgrade takes only 1-2 minutes and will not cause any downtime.
</p>
<Button
onClick={() => handlePopUpOpen("migrationInfo")}
isDisabled={!isAdmin || isUpgrading}
isLoading={migrateProjectToV3.isPending}
className="w-full"
>
{isAdmin ? "Upgrade Secrets Engine" : "Upgrade requires admin privilege"}
</Button>
{didProjectUpgradeFailed && (
<p className="mt-2 text-sm leading-7 text-red-400">
<FontAwesomeIcon icon={faTriangleExclamation} className="mr-2" />
Secrets engine upgrade unsuccessful. For assistance, please contact the Infisical support
team.
</p>
)}
<Modal
isOpen={popUp.migrationInfo.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("migrationInfo", isOpen)}
>
<ModalContent
title="Upgrade Checklist"
subTitle="To ensure smooth transition, please ensure the following requirements are met before upgrading this project."
>
<div>
<form onSubmit={handleSubmit(handleMigrationSecretV2)}>
<div className="flex flex-col space-y-4">
<Controller
control={control}
name="isCLIChecked"
defaultValue={false}
render={({ field: { onBlur, value, onChange }, fieldState: { error } }) => (
<Checkbox
id="is-cli-checked"
isChecked={value}
onCheckedChange={onChange}
onBlur={onBlur}
isError={Boolean(error?.message)}
>
Infisical CLI version is v0.25.0 or above.
</Checkbox>
)}
/>
<Controller
control={control}
name="isOperatorChecked"
defaultValue={false}
render={({ field: { onBlur, value, onChange }, fieldState: { error } }) => (
<Checkbox
id="is-operator-checked"
isChecked={value}
onCheckedChange={onChange}
onBlur={onBlur}
isError={Boolean(error?.message)}
>
Infisical Kubernetes Operator version is v0.7.0 or above.
</Checkbox>
)}
/>
<Controller
control={control}
name="shouldCloseOpenApprovals"
defaultValue={false}
render={({ field: { onBlur, value, onChange }, fieldState: { error } }) => (
<Checkbox
id="is-approvals-checked"
isChecked={value}
onCheckedChange={onChange}
onBlur={onBlur}
isError={Boolean(error?.message)}
>
Close/merge all open approval/access requests.
</Checkbox>
)}
/>
</div>
<div className="mt-8 flex space-x-4">
<Button type="submit">Confirm Upgrade</Button>
<Button variant="outline_bg" onClick={() => handlePopUpToggle("migrationInfo")}>
Cancel
</Button>
</div>
</form>
</div>
</ModalContent>
</Modal>
</div>
);
};
@@ -0,0 +1 @@
export { SecretV2MigrationSection } from "./SecretV2MigrationSection";
@@ -0,0 +1,206 @@
import { subject } from "@casl/ability";
import { faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
import {
SecretType,
SecretV3RawSanitized,
TDeleteSecretBatchDTO,
TSecretFolder
} from "@app/hooks/api/types";
export enum EntryType {
FOLDER = "folder",
SECRET = "secret"
}
type Props = {
secretPath: string;
resetSelectedEntries: () => void;
selectedEntries: {
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
};
};
export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries }: Props) => {
const { permission } = useProjectPermission();
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"bulkDeleteEntries"
] as const);
const selectedFolderCount = Object.keys(selectedEntries.folder).length;
const selectedKeysCount = Object.keys(selectedEntries.secret).length;
const selectedCount = selectedFolderCount + selectedKeysCount;
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const userAvailableEnvs = currentWorkspace?.environments || [];
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
const { mutateAsync: deleteFolder } = useDeleteFolder();
const isMultiSelectActive = selectedCount > 0;
// user should have the ability to delete secrets/folders in at least one of the envs
const shouldShowDelete = userAvailableEnvs.some((env) =>
permission.can(
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, {
environment: env.slug,
secretPath,
secretName: "*",
secretTags: ["*"]
})
)
);
const getDeleteModalTitle = () => {
if (selectedFolderCount > 0 && selectedKeysCount > 0) {
return "Do you want to delete the selected secrets and folders across environments?";
}
if (selectedKeysCount > 0) {
return "Do you want to delete the selected secrets across environments?";
}
return "Do you want to delete the selected folders across environments?";
};
const handleBulkDelete = async () => {
let processedEntries = 0;
const promises = userAvailableEnvs.map(async (env) => {
// additional check: ensure that bulk delete is only executed on envs that user has access to
if (
permission.can(
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.SecretFolders, { environment: env.slug, secretPath })
)
) {
await Promise.all(
Object.values(selectedEntries.folder).map(async (folderRecord) => {
const folder = folderRecord[env.slug];
if (folder) {
processedEntries += 1;
await deleteFolder({
folderId: folder?.id,
path: secretPath,
environment: env.slug,
projectId: workspaceId
});
}
})
);
}
const secretsToDelete = Object.values(selectedEntries.secret).reduce(
(accum: TDeleteSecretBatchDTO["secrets"], secretRecord) => {
const entry = secretRecord[env.slug];
const canDeleteSecret = permission.can(
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, {
environment: env.slug,
secretPath,
secretName: entry.key,
secretTags: (entry?.tags || []).map((i) => i.slug)
})
);
if (entry && canDeleteSecret) {
return [
...accum,
{
secretKey: entry.key,
type: SecretType.Shared
}
];
}
return accum;
},
[]
);
if (secretsToDelete.length > 0) {
processedEntries += secretsToDelete.length;
await deleteBatchSecretV3({
secretPath,
workspaceId,
environment: env.slug,
secrets: secretsToDelete
});
}
});
const results = await Promise.allSettled(promises);
const areEntriesDeleted = results.some((result) => result.status === "fulfilled");
if (processedEntries === 0) {
handlePopUpClose("bulkDeleteEntries");
createNotification({
type: "info",
text: "You don't have access to delete selected items"
});
} else if (areEntriesDeleted) {
handlePopUpClose("bulkDeleteEntries");
resetSelectedEntries();
createNotification({
type: "success",
text: "Successfully deleted selected secrets and folders"
});
} else {
createNotification({
type: "error",
text: "Failed to delete selected secrets and folders"
});
}
};
return (
<>
<div
className={twMerge(
"h-0 flex-shrink-0 overflow-hidden transition-all",
isMultiSelectActive && "h-16"
)}
>
<div className="mt-3.5 flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-bunker-300">
<Tooltip content="Clear">
<IconButton variant="plain" ariaLabel="clear-selection" onClick={resetSelectedEntries}>
<FontAwesomeIcon icon={faMinusSquare} size="lg" />
</IconButton>
</Tooltip>
<div className="ml-1 flex-grow px-2 text-sm">{selectedCount} Selected</div>
{shouldShowDelete && (
<Button
variant="outline_bg"
colorSchema="danger"
leftIcon={<FontAwesomeIcon icon={faTrash} />}
className="ml-4"
onClick={() => handlePopUpOpen("bulkDeleteEntries")}
size="xs"
>
Delete
</Button>
)}
</div>
</div>
<DeleteActionModal
isOpen={popUp.bulkDeleteEntries.isOpen}
deleteKey="delete"
title={getDeleteModalTitle()}
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
onDeleteApproved={handleBulkDelete}
/>
</>
);
};
@@ -0,0 +1,20 @@
import { createFileRoute, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { OverviewPage } from "./OverviewPage";
const SecretOverviewPageQuerySchema = z.object({
search: z.string().catch(""),
secretPath: z.string().catch("/")
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/overview"
)({
component: OverviewPage,
validateSearch: zodValidator(SecretOverviewPageQuerySchema),
search: {
middlewares: [stripSearchParams({ secretPath: "/", search: "" })]
}
});
@@ -0,0 +1,100 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import { useWorkspace } from "@app/context";
import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api";
import { AccessApprovalRequest } from "./components/AccessApprovalRequest";
import { ApprovalPolicyList } from "./components/ApprovalPolicyList";
import { SecretApprovalRequest } from "./components/SecretApprovalRequest";
enum TabSection {
SecretApprovalRequests = "approval-requests",
SecretPolicies = "approval-rules",
ResourcePolicies = "resource-rules",
ResourceApprovalRequests = "resource-requests",
Policies = "policies"
}
export const SecretApprovalsPage = () => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || "";
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({
workspaceId: projectId
});
const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug });
const defaultTab =
(accessApprovalRequestCount?.pendingCount || 0) > (secretApprovalReqCount?.open || 0)
? TabSection.ResourceApprovalRequests
: TabSection.SecretApprovalRequests;
return (
<div className="h-full">
<Helmet>
<title>{t("common.head-title", { title: t("approval.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={String(t("approval.og-title"))} />
<meta name="og:description" content={String(t("approval.og-description"))} />
</Helmet>
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 px-6 text-white">
<div className="flex items-center justify-between py-6">
<div className="flex w-full flex-col">
<h2 className="text-3xl font-semibold text-gray-200">Approval Workflows</h2>
<p className="text-bunker-300">
Create approval policies for any modifications to secrets in sensitive environments
and folders.
</p>
</div>
<div className="flex w-max justify-center">
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
target="_blank"
rel="noopener noreferrer"
>
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</a>
</div>
</div>
<Tabs defaultValue={defaultTab}>
<TabList>
<Tab value={TabSection.SecretApprovalRequests}>
Secret Requests
{Boolean(secretApprovalReqCount?.open) && (
<Badge className="ml-2">{secretApprovalReqCount?.open}</Badge>
)}
</Tab>
<Tab value={TabSection.ResourceApprovalRequests}>
Access Requests
{Boolean(accessApprovalRequestCount?.pendingCount) && (
<Badge className="ml-2">{accessApprovalRequestCount?.pendingCount}</Badge>
)}
</Tab>
<Tab value={TabSection.Policies}>Policies</Tab>
</TabList>
<TabPanel value={TabSection.SecretApprovalRequests}>
<SecretApprovalRequest />
</TabPanel>
<TabPanel value={TabSection.ResourceApprovalRequests}>
<AccessApprovalRequest projectId={projectId} projectSlug={projectSlug} />
</TabPanel>
<TabPanel value={TabSection.Policies}>
<ApprovalPolicyList workspaceId={projectId} />
</TabPanel>
</Tabs>
</div>
</div>
);
};
@@ -0,0 +1,462 @@
/* eslint-disable no-nested-ternary */
/* eslint-disable react/jsx-no-useless-fragment */
import { useMemo, useState } from "react";
import {
faCheck,
faCheckCircle,
faChevronDown,
faLock,
faPlus
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
Tooltip
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useUser,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetWorkspaceUsers } from "@app/hooks/api";
import {
accessApprovalKeys,
useGetAccessApprovalPolicies,
useGetAccessApprovalRequests,
useGetAccessRequestsCount
} from "@app/hooks/api/accessApproval/queries";
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
import { queryClient } from "@app/hooks/api/reactQuery";
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { RequestAccessModal } from "./components/RequestAccessModal";
import { ReviewAccessRequestModal } from "./components/ReviewAccessModal";
const generateRequestText = (request: TAccessApprovalRequest, userId: string) => {
const { isTemporary } = request;
return (
<div className="flex w-full items-center justify-between text-sm">
<div>
Requested {isTemporary ? "temporary" : "permanent"} access to{" "}
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
{request.policy.secretPath}
</code>
in
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
{request.environmentName}
</code>
</div>
<div>
{request.requestedByUserId === userId && (
<span className="text-xs text-gray-500">
<Badge className="ml-1">Requested By You</Badge>
</span>
)}
</div>
</div>
);
};
export const AccessApprovalRequest = ({
projectSlug,
projectId
}: {
projectSlug: string;
projectId: string;
}) => {
const [selectedRequest, setSelectedRequest] = useState<
| (TAccessApprovalRequest & {
user: TWorkspaceUser["user"] | null;
isRequestedByCurrentUser: boolean;
isApprover: boolean;
})
| null
>(null);
const { handlePopUpOpen, popUp, handlePopUpClose } = usePopUp([
"requestAccess",
"reviewRequest",
"upgradePlan"
] as const);
const { permission } = useProjectPermission();
const { user } = useUser();
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const { data: members } = useGetWorkspaceUsers(projectId, true);
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
(prev, curr) => ({ ...prev, [curr.user.id]: curr }),
{}
);
const [statusFilter, setStatusFilter] = useState<"open" | "close">("open");
const [requestedByFilter, setRequestedByFilter] = useState<string | undefined>(undefined);
const [envFilter, setEnvFilter] = useState<string | undefined>(undefined);
const { data: requestCount } = useGetAccessRequestsCount({
projectSlug
});
const { data: policies, isPending: policiesLoading } = useGetAccessApprovalPolicies({
projectSlug
});
const { data: requests } = useGetAccessApprovalRequests({
projectSlug,
authorProjectMembershipId: requestedByFilter,
envSlug: envFilter
});
const filteredRequests = useMemo(() => {
if (statusFilter === "open")
return requests?.filter(
(request) =>
!request.policy.deletedAt &&
!request.isApproved &&
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
);
if (statusFilter === "close")
return requests?.filter(
(request) =>
request.policy.deletedAt ||
request.isApproved ||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
);
return requests;
}, [requests, statusFilter, requestedByFilter, envFilter]);
const generateRequestDetails = (request: TAccessApprovalRequest) => {
const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
const isRejectedByAnyone = request.reviewers.some(
({ status }) => status === ApprovalStatus.REJECTED
);
const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1;
const isAccepted = request.isApproved;
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const isRequestedByCurrentUser = request.requestedByUserId === user.id;
const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status;
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
label: "",
type: "primary"
};
const isExpired =
request.privilege &&
request.isApproved &&
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
if (isExpired) displayData = { label: "Access Expired", type: "danger" };
else if (isAccepted) displayData = { label: "Access Granted", type: "success" };
else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" };
else if (userReviewStatus === ApprovalStatus.APPROVED) {
displayData = {
label: `Pending ${request.policy.approvals - request.reviewers.length} review${
request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
}`,
type: "primary"
};
} else if (!isReviewedByUser)
displayData = {
label: "Review Required",
type: "primary"
};
return {
displayData,
isReviewedByUser,
isRejectedByAnyone,
isApprover,
userReviewStatus,
isAccepted,
isSoftEnforcement,
isRequestedByCurrentUser
};
};
return (
<div>
<div className="mb-6 flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl font-semibold text-mineshaft-100">Access Requests</span>
<div className="mt-2 text-sm text-bunker-300">
Request access to secrets in sensitive environments and folders.
</div>
</div>
<div>
<Tooltip
content="To submit Access Requests, your project needs to create Access Request policies first."
isDisabled={policiesLoading || !!policies?.length}
>
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("requestAccess");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={policiesLoading || !policies?.length}
>
Request access
</Button>
</Tooltip>
</div>
</div>
<AnimatePresence>
<motion.div
key="approval-changes-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300"
>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
<div
role="button"
tabIndex={0}
onClick={() => setStatusFilter("open")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open");
}}
className={
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
}
>
<FontAwesomeIcon icon={faLock} className="mr-2" />
{!!requestCount && requestCount?.pendingCount} Pending
</div>
<div
className={
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
}
role="button"
tabIndex={0}
onClick={() => setStatusFilter("close")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("close");
}}
>
<FontAwesomeIcon icon={faCheck} className="mr-2" />
{!!requestCount && requestCount.finalizedCount} Completed
</div>
<div className="flex flex-grow justify-end space-x-8">
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className="text-bunker-300"
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
>
Environments
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
key={`request-filter-${slug}`}
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
{name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className={requestedByFilter ? "text-white" : "text-bunker-300"}
rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
>
Requested By
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
{members?.map(({ user: membershipUser, id }) => (
<DropdownMenuItem
onClick={() =>
setRequestedByFilter((state) => (state === id ? undefined : id))
}
key={`request-filter-member-${id}`}
icon={requestedByFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
{membershipUser.username}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{filteredRequests?.length === 0 && (
<div className="py-12">
<EmptyState title="No more access requests pending." />
</div>
)}
{!!filteredRequests?.length &&
filteredRequests?.map((request) => {
const details = generateRequestDetails(request);
return (
<div
aria-disabled={
details.isReviewedByUser || details.isRejectedByAnyone || details.isAccepted
}
key={request.id}
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
role="button"
tabIndex={0}
onClick={() => {
if (
(!details.isApprover ||
details.isReviewedByUser ||
details.isRejectedByAnyone ||
details.isAccepted) &&
!(
details.isSoftEnforcement &&
details.isRequestedByCurrentUser &&
!details.isAccepted
)
)
return;
if (membersGroupById?.[request.requestedByUserId].user) {
setSelectedRequest({
...request,
user: membersGroupById?.[request.requestedByUserId].user,
isRequestedByCurrentUser: details.isRequestedByCurrentUser,
isApprover: details.isApprover
});
}
handlePopUpOpen("reviewRequest");
}}
onKeyDown={(evt) => {
if (
!details.isApprover ||
details.isAccepted ||
details.isReviewedByUser ||
details.isRejectedByAnyone
)
return;
if (evt.key === "Enter") {
if (membersGroupById?.[request.requestedByUserId].user) {
setSelectedRequest({
...request,
user: membersGroupById?.[request.requestedByUserId].user,
isRequestedByCurrentUser: details.isRequestedByCurrentUser,
isApprover: details.isApprover
});
}
handlePopUpOpen("reviewRequest");
}
}}
>
<div className="w-full">
<div className="flex w-full flex-col justify-between">
<div className="mb-1 flex w-full items-center">
<FontAwesomeIcon icon={faLock} className="mr-2" />
{generateRequestText(request, user.id)}
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-gray-500">
{membersGroupById?.[request.requestedByUserId]?.user && (
<>
Requested {formatDistance(new Date(request.createdAt), new Date())}{" "}
ago by{" "}
{membersGroupById?.[request.requestedByUserId]?.user?.firstName}{" "}
{membersGroupById?.[request.requestedByUserId]?.user?.lastName} (
{membersGroupById?.[request.requestedByUserId]?.user?.email}){" "}
</>
)}
</div>
<div>
{details.isApprover && (
<Badge variant={details.displayData.type}>
{details.displayData.label}
</Badge>
)}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</motion.div>
</AnimatePresence>
{!!policies && (
<RequestAccessModal
policies={policies}
isOpen={popUp.requestAccess.isOpen}
onOpenChange={() => {
queryClient.invalidateQueries({
queryKey: accessApprovalKeys.getAccessApprovalRequests(
projectSlug,
envFilter,
requestedByFilter
)
});
handlePopUpClose("requestAccess");
}}
/>
)}
{!!selectedRequest && (
<ReviewAccessRequestModal
selectedEnvSlug={envFilter}
selectedRequester={requestedByFilter}
projectSlug={projectSlug}
request={selectedRequest}
isOpen={popUp.reviewRequest.isOpen}
onOpenChange={() => {
handlePopUpClose("reviewRequest");
setSelectedRequest(null);
}}
/>
)}
<UpgradePlanModal
text="You need to upgrade your plan to access this feature"
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={() => handlePopUpClose("upgradePlan")}
/>
</div>
);
};
@@ -0,0 +1,25 @@
import { Modal, ModalContent } from "@app/components/v2";
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
import { SpecificPrivilegeSecretForm } from "@app/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection";
export const RequestAccessModal = ({
isOpen,
onOpenChange,
policies
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
policies: TAccessApprovalPolicy[];
}) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-4xl"
title="Request Access"
subTitle="Your role has limited permissions, please contact your administrator to gain access"
>
<SpecificPrivilegeSecretForm onClose={() => onOpenChange(false)} policies={policies} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,183 @@
import { useCallback, useMemo, useState } from "react";
import ms from "ms";
import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, Modal, ModalContent } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions } from "@app/context";
import { useReviewAccessRequest } from "@app/hooks/api";
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
import { TWorkspaceUser } from "@app/hooks/api/types";
export const ReviewAccessRequestModal = ({
isOpen,
onOpenChange,
request,
projectSlug,
selectedRequester,
selectedEnvSlug
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
request: TAccessApprovalRequest & {
user: TWorkspaceUser["user"] | null;
isRequestedByCurrentUser: boolean;
isApprover: boolean;
};
projectSlug: string;
selectedRequester: string | undefined;
selectedEnvSlug: string | undefined;
}) => {
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
const [byPassApproval, setByPassApproval] = useState(false);
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const accessDetails = {
env: request.environmentName,
// secret path will be inside $glob operator
secretPath: request.policy.secretPath,
read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)),
edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)),
create: request.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Create)
),
delete: request.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Delete)
),
temporaryAccess: {
isTemporary: request.isTemporary,
temporaryRange: request.temporaryRange
}
};
const requestedAccess = useMemo(() => {
const access: string[] = [];
if (accessDetails.read) access.push("Read");
if (accessDetails.edit) access.push("Edit");
if (accessDetails.create) access.push("Create");
if (accessDetails.delete) access.push("Delete");
return access.join(", ");
}, [accessDetails]);
const getAccessLabel = () => {
if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange)
return "Permanent";
// convert the range to human readable format
ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true });
return (
<Badge>
{`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
long: true
})} after approval`}
</Badge>
);
};
const reviewAccessRequest = useReviewAccessRequest();
const handleReview = useCallback(async (status: "approved" | "rejected") => {
setIsLoading(status);
try {
await reviewAccessRequest.mutateAsync({
requestId: request.id,
status,
projectSlug,
envSlug: selectedEnvSlug,
requestedBy: selectedRequester
});
} catch (error) {
console.error(error);
setIsLoading(null);
return;
}
createNotification({
title: `Request ${status}`,
text: `The request has been ${status}`,
type: status === "approved" ? "success" : "info"
});
setIsLoading(null);
onOpenChange(false);
}, []);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-4xl"
title="Review Request"
subTitle="Review the request and approve or deny access."
>
<div className="text-sm">
<span>
<span className="font-bold">
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
</span>{" "}
is requesting access to the following resource:
</span>
<div className="mb-2 mt-4 border-l border-blue-500 bg-blue-500/20 px-3 py-2 text-mineshaft-200">
<div className="mb-1 lowercase">
<span className="font-bold capitalize">Requested path: </span>
<Badge>{accessDetails.env + accessDetails.secretPath || ""}</Badge>
</div>
<div className="mb-1">
<span className="font-bold">Permissions: </span>
<Badge>{requestedAccess}</Badge>
</div>
<div>
<span className="font-bold">Access Type: </span>
<span>{getAccessLabel()}</span>
</div>
</div>
<div className="space-x-2">
<Button
isLoading={isLoading === "approved"}
isDisabled={
!!isLoading || (!request.isApprover && !byPassApproval && isSoftEnforcement)
}
onClick={() => handleReview("approved")}
className="mt-4"
size="sm"
colorSchema={!request.isApprover && isSoftEnforcement ? "danger" : "primary"}
>
Approve Request
</Button>
<Button
isLoading={isLoading === "rejected"}
isDisabled={!!isLoading}
onClick={() => handleReview("rejected")}
className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200"
size="sm"
>
Reject Request
</Button>
</div>
{isSoftEnforcement && request.isRequestedByCurrentUser && !request.isApprover && (
<div className="mt-4">
<Checkbox
onCheckedChange={(checked) => setByPassApproval(checked === true)}
isChecked={byPassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={byPassApproval ? "border-red bg-red hover:bg-red-600" : ""}
>
<span className="text-sm text-red">
Approve without waiting for requirements to be met (bypass policy protection)
</span>
</Checkbox>
</div>
)}
</div>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { AccessApprovalRequest } from "./AccessApprovalRequest";
@@ -0,0 +1,292 @@
import { useMemo, useState } from "react";
import {
faCheckCircle,
faChevronDown,
faFileShield,
faPlus
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
TProjectPermission,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useDeleteAccessApprovalPolicy,
useDeleteSecretApprovalPolicy,
useGetSecretApprovalPolicies,
useGetWorkspaceUsers,
useListWorkspaceGroups
} from "@app/hooks/api";
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
import { PolicyType } from "@app/hooks/api/policies/enums";
import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types";
import { AccessPolicyForm } from "./components/AccessPolicyModal";
import { ApprovalPolicyRow } from "./components/ApprovalPolicyRow";
interface IProps {
workspaceId: string;
}
const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => {
const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies(
{
projectSlug: currentWorkspace?.slug as string,
options: {
enabled:
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
!!currentWorkspace?.slug
}
}
);
const { data: secretPolicies, isPending: isSecretPoliciesLoading } = useGetSecretApprovalPolicies(
{
workspaceId: currentWorkspace?.id as string,
options: {
enabled:
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
!!currentWorkspace?.id
}
}
);
// merge data sorted by updatedAt
const policies = [
...(accessPolicies?.map((policy) => ({ ...policy, policyType: PolicyType.AccessPolicy })) ||
[]),
...(secretPolicies?.map((policy) => ({ ...policy, policyType: PolicyType.ChangePolicy })) || [])
].sort((a, b) => {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
return {
policies,
isLoading: isAccessPoliciesLoading || isSecretPoliciesLoading
};
};
export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([
"policyForm",
"deletePolicy",
"upgradePlan"
] as const);
const { permission } = useProjectPermission();
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const { data: members } = useGetWorkspaceUsers(workspaceId, true);
const { data: groups } = useListWorkspaceGroups(currentWorkspace?.id || "");
const { policies, isLoading: isPoliciesLoading } = useApprovalPolicies(
permission,
currentWorkspace
);
const [filterType, setFilterType] = useState<string | null>(null);
const filteredPolicies = useMemo(() => {
return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies;
}, [policies, filterType]);
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy();
const handleDeletePolicy = async () => {
const { id, policyType } = popUp.deletePolicy.data as TAccessApprovalPolicy;
if (!currentWorkspace?.slug) return;
try {
if (policyType === PolicyType.ChangePolicy) {
await deleteSecretApprovalPolicy({
workspaceId,
id
});
} else {
await deleteAccessApprovalPolicy({
projectSlug: currentWorkspace?.slug,
id
});
}
createNotification({
type: "success",
text: "Successfully deleted policy"
});
handlePopUpClose("deletePolicy");
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to delete policy"
});
}
};
return (
<div>
<div className="mb-6 flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl font-semibold text-mineshaft-100">Policies</span>
<div className="mt-2 text-sm text-bunker-300">
Implement granular policies for access requests and secrets management.
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("policyForm");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Create Policy
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th className="w-[18%]">Eligible Approvers</Th>
<Th className="w-[18%]">Eligible Group Approvers</Th>
<Th>Approval Required</Th>
<Th>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className="text-xs font-semibold uppercase text-bunker-300"
rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
>
Type
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select a type</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setFilterType(null)}
icon={!filterType && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
All
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setFilterType(PolicyType.AccessPolicy)}
icon={
filterType === PolicyType.AccessPolicy && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
Access Policy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setFilterType(PolicyType.ChangePolicy)}
icon={
filterType === PolicyType.ChangePolicy && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
Change Policy
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPoliciesLoading && (
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" />
)}
{!isPoliciesLoading && !filteredPolicies?.length && (
<Tr>
<Td colSpan={6}>
<EmptyState title="No policies found" icon={faFileShield} />
</Td>
</Tr>
)}
{!!currentWorkspace &&
filteredPolicies?.map((policy) => (
<ApprovalPolicyRow
policy={policy}
key={policy.id}
members={members}
groups={groups}
onEdit={() => handlePopUpOpen("policyForm", policy)}
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
/>
))}
</TBody>
</Table>
</TableContainer>
<AccessPolicyForm
projectId={currentWorkspace.id}
projectSlug={currentWorkspace.slug}
isOpen={popUp.policyForm.isOpen}
onToggle={(isOpen) => handlePopUpToggle("policyForm", isOpen)}
members={members}
editValues={popUp.policyForm.data as TAccessApprovalPolicy}
/>
<DeleteActionModal
isOpen={popUp.deletePolicy.isOpen}
deleteKey="remove"
title="Do you want to remove this policy?"
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
onDeleteApproved={handleDeletePolicy}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
/>
</div>
);
};
@@ -0,0 +1,457 @@
import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies";
import {
useCreateSecretApprovalPolicy,
useListWorkspaceGroups,
useUpdateSecretApprovalPolicy
} from "@app/hooks/api";
import {
useCreateAccessApprovalPolicy,
useUpdateAccessApprovalPolicy
} from "@app/hooks/api/accessApproval";
import { ApproverType, TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types";
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
import { TWorkspaceUser } from "@app/hooks/api/users/types";
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
members?: TWorkspaceUser[];
projectId: string;
projectSlug: string;
editValues?: TAccessApprovalPolicy;
};
const formSchema = z
.object({
environment: z.object({ slug: z.string(), name: z.string() }),
name: z.string().optional(),
secretPath: z.string().optional(),
approvals: z.number().min(1),
userApprovers: z
.object({ type: z.literal(ApproverType.User), id: z.string() })
.array()
.default([]),
groupApprovers: z
.object({ type: z.literal(ApproverType.Group), id: z.string() })
.array()
.default([]),
policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel)
})
.superRefine((data, ctx) => {
if (!(data.groupApprovers.length || data.userApprovers.length)) {
ctx.addIssue({
path: ["userApprovers"],
code: z.ZodIssueCode.custom,
message: "At least one approver should be provided"
});
ctx.addIssue({
path: ["groupApprovers"],
code: z.ZodIssueCode.custom,
message: "At least one approver should be provided"
});
}
});
type TFormSchema = z.infer<typeof formSchema>;
export const AccessPolicyForm = ({
isOpen,
onToggle,
members = [],
projectId,
projectSlug,
editValues
}: Props) => {
const {
control,
handleSubmit,
reset,
watch,
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
values: editValues
? {
...editValues,
environment: editValues.environment,
userApprovers:
editValues?.approvers
?.filter((approver) => approver.type === ApproverType.User)
.map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [],
groupApprovers:
editValues?.approvers
?.filter((approver) => approver.type === ApproverType.Group)
.map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [],
approvals: editValues?.approvals
}
: undefined
});
const { currentWorkspace } = useWorkspace();
const { data: groups } = useListWorkspaceGroups(projectId);
const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues);
useEffect(() => {
if (!isOpen || !isEditMode) reset({});
}, [isOpen, isEditMode]);
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy();
const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy();
const policyName = policyDetails[watch("policyType")]?.name || "Policy";
const approversRequired = watch("approvals") || 1;
const handleCreatePolicy = async ({
environment,
groupApprovers,
userApprovers,
...data
}: TFormSchema) => {
if (!projectId) return;
try {
if (data.policyType === PolicyType.ChangePolicy) {
await createSecretApprovalPolicy({
...data,
approvers: [...userApprovers, ...groupApprovers],
environment: environment.slug,
workspaceId: currentWorkspace?.id || ""
});
} else {
await createAccessApprovalPolicy({
...data,
approvers: [...userApprovers, ...groupApprovers],
environment: environment.slug,
projectSlug
});
}
createNotification({
type: "success",
text: "Successfully created policy"
});
onToggle(false);
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to create policy"
});
}
};
const handleUpdatePolicy = async ({
environment,
userApprovers,
groupApprovers,
...data
}: TFormSchema) => {
if (!projectId || !projectSlug) return;
if (!editValues?.id) return;
try {
if (data.policyType === PolicyType.ChangePolicy) {
await updateSecretApprovalPolicy({
id: editValues?.id,
...data,
approvers: [...userApprovers, ...groupApprovers],
workspaceId: currentWorkspace?.id || ""
});
} else {
await updateAccessApprovalPolicy({
id: editValues?.id,
...data,
approvers: [...userApprovers, ...groupApprovers],
environment: environment.slug,
projectSlug
});
}
createNotification({
type: "success",
text: "Successfully updated policy"
});
onToggle(false);
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "failed to update policy"
});
}
};
const handleFormSubmit = async (data: TFormSchema) => {
if (isEditMode) {
await handleUpdatePolicy(data);
} else {
await handleCreatePolicy(data);
}
};
const memberOptions = useMemo(
() =>
members.map((member) => ({
id: member.user.id,
type: ApproverType.User
})),
[members]
);
const groupOptions = useMemo(
() =>
groups?.map(({ group }) => ({
id: group.id,
type: ApproverType.Group
})),
[groups]
);
return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent
className="max-w-2xl"
bodyClassName="overflow-visible"
title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
>
<div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="grid grid-cols-2 gap-x-3">
<Controller
control={control}
name="policyType"
defaultValue={PolicyType.ChangePolicy}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Policy Type"
isRequired
isError={Boolean(error)}
tooltipText="Change polices govern secret changes within a given environment and secret path. Access polices allow underprivileged user to request access to environment/secret path."
errorText={error?.message}
>
<Select
isDisabled={isEditMode}
value={value}
onValueChange={(val) => onChange(val as PolicyType)}
className="w-full border border-mineshaft-500"
>
{Object.values(PolicyType).map((policyType) => {
return (
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
{policyDetails[policyType].name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Minimum Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="number"
min={1}
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Policy Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Enforcement Level"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
<>
<p>
Determines the level of enforcement for required approvers of a request:
</p>
<p className="mt-2">
<span className="font-bold">Hard</span> enforcement requires at least{" "}
<span className="font-bold"> {approversRequired}</span> approver(s) to
approve the request.`
</p>
<p className="mt-2">
<span className="font-bold">Soft</span> enforcement At least{" "}
<span className="font-bold">{approversRequired}</span> approver(s) must
approve the request; however, the requester can bypass approval
requirements in emergencies.
</p>
</>
}
>
<Select
value={field.value}
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
className="w-full border border-mineshaft-500"
>
{Object.values(EnforcementLevel).map((level) => {
return (
<SelectItem value={level} key={`enforcement-level-${level}`}>
<span className="capitalize">{level}</span>
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
</div>
<div className="mb-2">
<p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
Select members or groups that are allowed to approve requests from this policy.
</p>
</div>
<Controller
control={control}
name="userApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members that are allowed to approve requests..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups that are allowed to approve requests..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save
</Button>
<Button onClick={() => onToggle(false)} variant="outline_bg">
Close
</Button>
</div>
</form>
</div>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,164 @@
import { useMemo } from "react";
import { faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies";
import { Approver } from "@app/hooks/api/accessApproval/types";
import { TGroupMembership } from "@app/hooks/api/groups/types";
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
import { ApproverType } from "@app/hooks/api/secretApproval/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { TWorkspaceUser } from "@app/hooks/api/users/types";
interface IPolicy {
id: string;
name: string;
environment: WorkspaceEnv;
projectId?: string;
secretPath?: string;
approvals: number;
approvers?: Approver[];
updatedAt: Date;
policyType: PolicyType;
enforcementLevel: EnforcementLevel;
}
type Props = {
policy: IPolicy;
members?: TWorkspaceUser[];
groups?: TGroupMembership[];
onEdit: () => void;
onDelete: () => void;
};
export const ApprovalPolicyRow = ({
policy,
members = [],
groups = [],
onEdit,
onDelete
}: Props) => {
const labels = useMemo(() => {
const usersInPolicy = policy.approvers
?.filter((approver) => approver.type === ApproverType.User)
.map((approver) => approver.id);
const groupsInPolicy = policy.approvers
?.filter((approver) => approver.type === ApproverType.Group)
.map((approver) => approver.id);
const memberLabels = usersInPolicy?.length
? members
.filter((member) => usersInPolicy?.includes(member.user.id))
.map((member) => getMemberLabel(member))
.join(", ")
: null;
const groupLabels = groupsInPolicy?.length
? groups
.filter(({ group }) => groupsInPolicy?.includes(group.id))
.map(({ group }) => group.name)
.join(", ")
: null;
return {
members: memberLabels,
groups: groupLabels
};
}, [policy, members, groups]);
return (
<Tr>
<Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td className="max-w-0">
<Tooltip
side="left"
content={labels.members ?? "No users are assigned as approvers for this policy"}
>
<p className="truncate">{labels.members ?? "-"}</p>
</Tooltip>
</Td>
<Td className="max-w-0">
<Tooltip
side="left"
content={labels.groups ?? "No groups are assigned as approvers for this policy"}
>
<p className="truncate">{labels.groups ?? "-"}</p>
</Tooltip>
</Td>
<Td>{policy.approvals}</Td>
<Td>
<Badge className={policyDetails[policy.policyType].className}>
{policyDetails[policy.policyType].name}
</Badge>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
disabled={!isAllowed}
>
Edit Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
disabled={!isAllowed}
>
Delete Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
};
@@ -0,0 +1 @@
export { ApprovalPolicyList } from "./ApprovalPolicyList";
@@ -0,0 +1,291 @@
import { Fragment, useEffect, useState } from "react";
import {
faCheck,
faCheckCircle,
faChevronDown,
faCodeBranch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSearch } from "@tanstack/react-router";
import { formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
Skeleton
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useUser,
useWorkspace
} from "@app/context";
import {
useGetSecretApprovalRequestCount,
useGetSecretApprovalRequests,
useGetWorkspaceUsers
} from "@app/hooks/api";
import { ApprovalStatus } from "@app/hooks/api/types";
import {
generateCommitText,
SecretApprovalRequestChanges
} from "./components/SecretApprovalRequestChanges";
export const SecretApprovalRequest = () => {
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const [selectedApprovalId, setSelectedApprovalId] = useState<string | null>(null);
// filters
const [statusFilter, setStatusFilter] = useState<"open" | "close">("open");
const [envFilter, setEnvFilter] = useState<string>();
const [committerFilter, setCommitterFilter] = useState<string>();
const [usingUrlRequestId, setUsingUrlRequestId] = useState(false);
const {
data: secretApprovalRequests,
isFetchingNextPage: isFetchingNextApprovalRequest,
fetchNextPage: fetchNextApprovalRequest,
hasNextPage: hasNextApprovalPage,
isPending: isApprovalRequestLoading,
refetch
} = useGetSecretApprovalRequests({
workspaceId,
status: statusFilter,
environment: envFilter,
committer: committerFilter
});
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
useGetSecretApprovalRequestCount({ workspaceId });
const { user: userSession } = useUser();
const search = useSearch({
from: ROUTE_PATHS.SecretManager.ApprovalPage.id
});
const { permission } = useProjectPermission();
const { data: members } = useGetWorkspaceUsers(workspaceId);
const isSecretApprovalScreen = Boolean(selectedApprovalId);
const { requestId } = search;
useEffect(() => {
if (!requestId || usingUrlRequestId) return;
setSelectedApprovalId(requestId as string);
setUsingUrlRequestId(true);
}, [requestId]);
const handleGoBackSecretRequestDetail = () => {
setSelectedApprovalId(null);
refetch();
};
const isRequestListEmpty =
!isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0;
return (
<AnimatePresence mode="wait">
{isSecretApprovalScreen ? (
<motion.div
key="approval-changes-details"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<SecretApprovalRequestChanges
workspaceId={workspaceId}
approvalRequestId={selectedApprovalId || ""}
onGoBack={handleGoBackSecretRequestDetail}
/>
</motion.div>
) : (
<motion.div
key="approval-changes-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300"
>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
<div
role="button"
tabIndex={0}
onClick={() => setStatusFilter("open")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open");
}}
className={
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
}
>
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
</div>
<div
className={
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
}
role="button"
tabIndex={0}
onClick={() => setStatusFilter("close")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("close");
}}
>
<FontAwesomeIcon icon={faCheck} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
</div>
<div className="flex flex-grow justify-end space-x-8">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="plain"
colorSchema="secondary"
className={envFilter ? "text-white" : "text-bunker-300"}
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
>
Environments
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
key={`request-filter-${slug}`}
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
{name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className={committerFilter ? "text-white" : "text-bunker-300"}
rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
>
Author
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
{members?.map(({ user, id }) => (
<DropdownMenuItem
onClick={() =>
setCommitterFilter((state) => (state === user.id ? undefined : user.id))
}
key={`request-filter-member-${id}`}
icon={
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
}
iconPos="right"
>
{user.username}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{isRequestListEmpty && (
<div className="py-12">
<EmptyState title="No more requests pending." />
</div>
)}
{secretApprovalRequests?.pages?.map((group, i) => (
<Fragment key={`secret-approval-request-${i + 1}`}>
{group?.map((secretApproval) => {
const {
id: reqId,
commits,
createdAt,
reviewers,
status,
committerUser,
isReplicated: isReplication
} = secretApproval;
const isReviewed = reviewers.some(
({ status: reviewStatus, userId }) =>
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
);
return (
<div
key={reqId}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onClick={() => setSelectedApprovalId(secretApproval.id)}
onKeyDown={(evt) => {
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
}}
>
<div className="mb-1">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{generateCommitText(commits)}
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
</div>
<span className="text-xs text-gray-500">
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
{committerUser?.email}){isReplication && " via replication"}
{!isReviewed && status === "open" && " - Review required"}
</span>
</div>
);
})}
</Fragment>
))}
{(isFetchingNextApprovalRequest || isApprovalRequestLoading) && (
<div>
{Array.apply(0, Array(3)).map((_x, index) => (
<div
key={`approval-request-loading-${index + 1}`}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
>
<div className="mb-2 flex items-center">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
<Skeleton className="w-1/4 bg-mineshaft-600" />
</div>
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
))}
</div>
)}
</div>
{hasNextApprovalPage && (
<Button
className="mt-4 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextApprovalRequest}
isDisabled={isFetchingNextApprovalRequest || !hasNextApprovalPage}
onClick={() => fetchNextApprovalRequest()}
>
{hasNextApprovalPage ? "Load More" : "End of history"}
</Button>
)}
</motion.div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,225 @@
import { useState } from "react";
import {
faCheck,
faClose,
faLandMineOn,
faLockOpen,
faSquareCheck,
faSquareXmark,
faTriangleExclamation,
faUserLock
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, FormControl, Input } from "@app/components/v2";
import {
usePerformSecretApprovalRequestMerge,
useUpdateSecretApprovalRequestStatus
} from "@app/hooks/api";
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
type Props = {
approvalRequestId: string;
hasMerged?: boolean;
isMergable?: boolean;
status: "close" | "open";
approvals: number;
canApprove?: boolean;
statusChangeByEmail?: string;
workspaceId: string;
enforcementLevel: EnforcementLevel;
};
export const SecretApprovalRequestAction = ({
approvalRequestId,
hasMerged,
status,
isMergable,
approvals,
statusChangeByEmail,
workspaceId,
enforcementLevel,
canApprove
}: Props) => {
const { mutateAsync: performSecretApprovalMerge, isPending: isMerging } =
usePerformSecretApprovalRequestMerge();
const { mutateAsync: updateSecretStatusChange, isPending: isStatusChanging } =
useUpdateSecretApprovalRequestStatus();
const [byPassApproval, setByPassApproval] = useState(false);
const [bypassReason, setBypassReason] = useState("");
const isValidBypassReason = (value: string) => {
const trimmedValue = value.trim();
return trimmedValue.length >= 10;
};
const handleSecretApprovalRequestMerge = async () => {
try {
await performSecretApprovalMerge({
id: approvalRequestId,
workspaceId,
bypassReason: byPassApproval ? bypassReason : undefined
});
createNotification({
type: "success",
text: "Successfully merged the request"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to update the request status"
});
}
};
const handleSecretApprovalStatusChange = async (reqState: "open" | "close") => {
try {
await updateSecretStatusChange({
id: approvalRequestId,
status: reqState,
workspaceId
});
createNotification({
type: "success",
text: "Successfully updated the request"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to update the request status"
});
}
};
const isSoftEnforcement = enforcementLevel === EnforcementLevel.Soft;
if (!hasMerged && status === "open") {
return (
<div className="flex w-full items-start justify-between transition-all">
<div className="flex items-start space-x-4">
<FontAwesomeIcon
icon={isMergable ? faSquareCheck : faSquareXmark}
className={twMerge("pt-1 text-2xl", isMergable ? "text-primary" : "text-red-600")}
/>
<span className="flex flex-col">
{isMergable ? "Good to merge" : "Review required"}
<span className="inline-block text-xs text-bunker-200">
At least {approvals} approving review required
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
</span>
{isSoftEnforcement && !isMergable && (
<div className="mt-2 flex flex-col space-y-2">
<Checkbox
onCheckedChange={(checked) => setByPassApproval(checked === true)}
isChecked={byPassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={twMerge(
"mr-2",
byPassApproval ? "border-red bg-red hover:bg-red-600" : ""
)}
>
<span className="text-xs text-red">
Merge without waiting for approval (bypass secret change policy)
</span>
</Checkbox>
{byPassApproval && (
<FormControl
label="Reason for bypass"
className="mt-2"
isRequired
tooltipText="Enter a reason for bypassing the secret change policy"
>
<Input
value={bypassReason}
onChange={(e) => setBypassReason(e.target.value)}
placeholder="Enter reason for bypass (min 10 chars)"
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
/>
</FormControl>
)}
</div>
)}
</span>
</div>
<div className="flex items-center space-x-2">
{canApprove || isSoftEnforcement ? (
<>
<Button
onClick={() => handleSecretApprovalStatusChange("close")}
isLoading={isStatusChanging}
variant="outline_bg"
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faClose} />}
>
Close request
</Button>
<Button
leftIcon={<FontAwesomeIcon icon={!canApprove ? faLandMineOn : faCheck} />}
isDisabled={
!(
(isMergable && canApprove) ||
(isSoftEnforcement && byPassApproval && isValidBypassReason(bypassReason))
)
}
isLoading={isMerging}
onClick={handleSecretApprovalRequestMerge}
colorSchema={isSoftEnforcement && !canApprove ? "danger" : "primary"}
variant="solid"
>
Merge
</Button>
</>
) : (
<div>Only approvers can merge</div>
)}
</div>
</div>
);
}
if (hasMerged && status === "close")
return (
<div className="flex w-full items-center justify-between">
<div className="flex items-start space-x-4">
<FontAwesomeIcon icon={faCheck} className="pt-1 text-2xl text-primary" />
<span className="flex flex-col">
Secret approval merged
<span className="inline-block text-xs text-bunker-200">
Merged by {statusChangeByEmail}
</span>
</span>
</div>
</div>
);
return (
<div className="flex w-full items-center justify-between">
<div className="flex items-start space-x-4">
<FontAwesomeIcon icon={faUserLock} className="pt-1 text-2xl text-primary" />
<span className="flex flex-col">
Secret approval has been closed
<span className="inline-block text-xs text-bunker-200">
Closed by {statusChangeByEmail}
</span>
</span>
</div>
<div className="flex items-center space-x-6">
<Button
onClick={() => handleSecretApprovalStatusChange("open")}
isLoading={isStatusChanging}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
>
Reopen request
</Button>
</div>
</div>
);
};
@@ -0,0 +1,268 @@
import { faExclamationTriangle, faInfo, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
SecretInput,
Table,
TableContainer,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { CommitType, SecretV3Raw, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types";
export type Props = {
op: CommitType;
secretVersion?: SecretV3Raw;
newVersion?: Omit<TSecretApprovalSecChange, "tags"> & {
tags?: WsTag[];
secretMetadata?: { key: string; value: string }[];
};
presentSecretVersionNumber: number;
hasMerged?: boolean;
conflicts: Array<{ secretId: string; op: CommitType }>;
};
const generateItemTitle = (op: CommitType) => {
let text = { label: "", color: "" };
if (op === CommitType.CREATE) text = { label: "create", color: "#16a34a" };
else if (op === CommitType.UPDATE) text = { label: "change", color: "#ea580c" };
else text = { label: "deletion", color: "#b91c1c" };
return (
<span>
Request for <span style={{ color: text.color }}>secret {text.label}</span>
</span>
);
};
const generateConflictText = (op: CommitType) => {
if (op === CommitType.CREATE) return <div>Secret already exist</div>;
if (op === CommitType.UPDATE) return <div>Secret not found</div>;
return null;
};
export const SecretApprovalRequestChangeItem = ({
op,
secretVersion,
newVersion,
presentSecretVersionNumber,
hasMerged,
conflicts
}: Props) => {
// meaning request has changed
const isStale = (secretVersion?.version || 1) < presentSecretVersionNumber;
const itemConflict =
hasMerged && conflicts.find((el) => el.op === op && el.secretId === newVersion?.id);
const hasConflict = Boolean(itemConflict);
return (
<div className="rounded-lg bg-bunker-500 px-4 pb-4 pt-2">
<div className="flex items-center px-1 py-3">
<div className="flex-grow">{generateItemTitle(op)}</div>
{!hasMerged && isStale && (
<div className="flex items-center">
<FontAwesomeIcon icon={faInfo} className="text-sm text-primary-600" />
<span className="ml-2 text-xs">Secret has been changed(stale)</span>
</div>
)}
{hasMerged && hasConflict && (
<div className="flex items-center space-x-2 text-sm text-bunker-300">
<Tooltip content="Merge Conflict">
<FontAwesomeIcon icon={faExclamationTriangle} className="text-red-700" />
</Tooltip>
<div>{generateConflictText(op)}</div>
</div>
)}
</div>
<TableContainer>
<Table>
<THead>
<Tr>
{op === CommitType.UPDATE && <Th className="w-12 shrink-0" />}
<Th className="w-48 shrink-0">Secret</Th>
<Th className="min-w-0 flex-1">Value</Th>
<Th className="w-24 shrink-0">Comment</Th>
<Th className="w-24 shrink-0">Tags</Th>
<Th className="w-40 shrink-0">Metadata</Th>
</Tr>
</THead>
{op === CommitType.UPDATE ? (
<TBody>
<Tr>
<Td className="text-red-600">OLD</Td>
<Td>{secretVersion?.secretKey}</Td>
<Td>
<SecretInput isReadOnly value={secretVersion?.secretValue} />
</Td>
<Td>{secretVersion?.secretComment}</Td>
<Td className="flex flex-wrap gap-2">
{secretVersion?.tags?.map(({ slug, id: tagId, color }) => (
<Tag
className="flex w-min items-center space-x-2"
key={`${secretVersion.id}-${tagId}`}
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: color || "#bec2c8" }}
/>
<div className="text-sm">{slug}</div>
</Tag>
))}
</Td>
<Td>
{secretVersion?.secretMetadata?.length ? (
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
{secretVersion.secretMetadata?.map((el) => (
<div key={el.key} className="flex items-center">
<Tag
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tag>
</div>
))}
</div>
) : (
<p className="text-sm text-mineshaft-300">-</p>
)}
</Td>
</Tr>
<Tr>
<Td className="text-green-600">NEW</Td>
<Td>{newVersion?.secretKey}</Td>
<Td>
<SecretInput isReadOnly value={newVersion?.secretValue} />
</Td>
<Td>{newVersion?.secretComment}</Td>
<Td className="flex flex-wrap gap-2">
{newVersion?.tags?.map(({ slug, id: tagId, color }) => (
<Tag
className="flex w-min items-center space-x-2"
key={`${newVersion.id}-${tagId}`}
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: color || "#bec2c8" }}
/>
<div className="text-sm">{slug}</div>
</Tag>
))}
</Td>
<Td>
{newVersion?.secretMetadata?.length ? (
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
{newVersion.secretMetadata?.map((el) => (
<div key={el.key} className="flex items-center">
<Tag
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tag>
</div>
))}
</div>
) : (
<p className="text-sm text-mineshaft-300">-</p>
)}
</Td>
</Tr>
</TBody>
) : (
<TBody>
<Tr>
<Td>
{op === CommitType.CREATE ? newVersion?.secretKey : secretVersion?.secretKey}
</Td>
<Td>
<SecretInput
isReadOnly
value={
op === CommitType.CREATE
? newVersion?.secretValue
: secretVersion?.secretValue
}
/>
</Td>
<Td>
{op === CommitType.CREATE
? newVersion?.secretComment
: secretVersion?.secretComment}
</Td>
<Td>
{(op === CommitType.CREATE ? newVersion?.tags : secretVersion?.tags)?.map(
({ slug, id: tagId, color }) => (
<Tag
className="flex w-min items-center space-x-2"
key={`${
op === CommitType.CREATE ? newVersion?.id : secretVersion?.id
}-${tagId}`}
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: color || "#bec2c8" }}
/>
<div className="text-sm">{slug}</div>
</Tag>
)
)}
</Td>
<Td>
{newVersion?.secretMetadata?.length ? (
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
{newVersion.secretMetadata?.map((el) => (
<div key={el.key} className="flex items-center">
<Tag
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tag>
</div>
))}
</div>
) : (
<p className="text-sm text-mineshaft-300">-</p>
)}
</Td>
</Tr>
</TBody>
)}
</Table>
</TableContainer>
</div>
);
};
@@ -0,0 +1,317 @@
import { ReactNode } from "react";
import {
faArrowLeft,
faCheck,
faCheckCircle,
faCircle,
faCodeBranch,
faFolder,
faXmarkCircle
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button, ContentLoader, EmptyState, IconButton, Tooltip } from "@app/components/v2";
import { useUser } from "@app/context";
import {
useGetSecretApprovalRequestDetails,
useUpdateSecretApprovalReviewStatus
} from "@app/hooks/api";
import { ApprovalStatus, CommitType } from "@app/hooks/api/types";
import { formatReservedPaths } from "@app/lib/fn/string";
import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction";
import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem";
export const generateCommitText = (commits: { op: CommitType }[] = []) => {
const score: Record<string, number> = {};
commits.forEach(({ op }) => {
score[op] = (score?.[op] || 0) + 1;
});
const text: ReactNode[] = [];
if (score[CommitType.CREATE])
text.push(
<span key="created-commit">
{score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"}
<span style={{ color: "#16a34a" }}> created</span>
</span>
);
if (score[CommitType.UPDATE])
text.push(
<span key="updated-commit">
{Boolean(text.length) && ","}
{score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#ea580c" }} className="text-orange-600">
{" "}
updated
</span>
</span>
);
if (score[CommitType.DELETE])
text.push(
<span className="deleted-commit">
{Boolean(text.length) && "and"}
{score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#b91c1c" }}> deleted</span>
</span>
);
return text;
};
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
if (status === ApprovalStatus.APPROVED)
return <FontAwesomeIcon icon={faCheckCircle} size="xs" style={{ color: "#15803d" }} />;
if (status === ApprovalStatus.REJECTED)
return <FontAwesomeIcon icon={faXmarkCircle} size="xs" style={{ color: "#b91c1c" }} />;
return <FontAwesomeIcon icon={faCircle} size="xs" style={{ color: "#c2410c" }} />;
};
type Props = {
workspaceId: string;
approvalRequestId: string;
onGoBack: () => void;
};
export const SecretApprovalRequestChanges = ({
approvalRequestId,
onGoBack,
workspaceId
}: Props) => {
const { user: userSession } = useUser();
const {
data: secretApprovalRequestDetails,
isSuccess: isSecretApprovalRequestSuccess,
isPending: isSecretApprovalRequestLoading
} = useGetSecretApprovalRequestDetails({
id: approvalRequestId
});
const {
mutateAsync: updateSecretApprovalRequestStatus,
isPending: isUpdatingRequestStatus,
variables
} = useUpdateSecretApprovalReviewStatus();
const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus;
const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus;
// membership of present user
const canApprove = secretApprovalRequestDetails?.policy?.approvers?.some(
({ userId }) => userId === userSession.id
);
const reviewedUsers = secretApprovalRequestDetails?.reviewers?.reduce<
Record<string, ApprovalStatus>
>(
(prev, curr) => ({
...prev,
[curr.userId]: curr.status
}),
{}
);
const hasApproved = reviewedUsers?.[userSession.id] === ApprovalStatus.APPROVED;
const hasRejected = reviewedUsers?.[userSession.id] === ApprovalStatus.REJECTED;
const handleSecretApprovalStatusUpdate = async (status: ApprovalStatus) => {
try {
await updateSecretApprovalRequestStatus({
id: approvalRequestId,
status
});
createNotification({
type: "success",
text: `Successfully ${status} the request`
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to update the request status"
});
}
};
if (isSecretApprovalRequestLoading) {
return (
<div>
<ContentLoader />
</div>
);
}
if (!isSecretApprovalRequestSuccess)
return (
<div>
<EmptyState title="Failed to load approvals" />
</div>
);
const isMergable =
secretApprovalRequestDetails?.policy?.approvals <=
secretApprovalRequestDetails?.policy?.approvers?.filter(
({ userId }) => reviewedUsers?.[userId] === ApprovalStatus.APPROVED
).length;
const hasMerged = secretApprovalRequestDetails?.hasMerged;
return (
<div className="flex space-x-6">
<div className="flex-grow">
<div className="sticky top-0 z-20 flex items-center space-x-4 bg-bunker-800 pb-6 pt-2">
<IconButton variant="outline_bg" ariaLabel="go-back" onClick={onGoBack}>
<FontAwesomeIcon icon={faArrowLeft} />
</IconButton>
<div
className={twMerge(
"flex items-center space-x-2 rounded-3xl px-4 py-2 text-white",
secretApprovalRequestDetails.status === "close" ? "bg-red-600" : "bg-green-600"
)}
>
<FontAwesomeIcon icon={faCodeBranch} size="sm" />
<span className="capitalize">
{secretApprovalRequestDetails.status === "close"
? "closed"
: secretApprovalRequestDetails.status}
</span>
</div>
<div className="flex flex-grow flex-col">
<div className="mb-1 text-lg">
{generateCommitText(secretApprovalRequestDetails.commits)}
{secretApprovalRequestDetails.isReplicated && (
<span className="text-sm text-bunker-300"> (replication)</span>
)}
</div>
<div className="flex items-center text-sm text-bunker-300">
{secretApprovalRequestDetails?.committerUser?.firstName || ""}
{secretApprovalRequestDetails?.committerUser?.lastName || ""} (
{secretApprovalRequestDetails?.committerUser?.email}) wants to change{" "}
{secretApprovalRequestDetails.commits.length} secret values in
<span className="mx-1 rounded bg-primary-600/60 px-1 text-primary-300">
{secretApprovalRequestDetails.environment}
</span>
<div className="flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
<div className="border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
</div>
<Tooltip content={formatReservedPaths(secretApprovalRequestDetails.secretPath)}>
<div className="truncate pb-0.5 pl-2 text-sm" style={{ maxWidth: "10rem" }}>
{formatReservedPaths(secretApprovalRequestDetails.secretPath)}
</div>
</Tooltip>
</div>
</div>
</div>
{!hasMerged && secretApprovalRequestDetails.status === "open" && (
<>
<Button
size="xs"
leftIcon={hasApproved && <FontAwesomeIcon icon={faCheck} />}
onClick={() => handleSecretApprovalStatusUpdate(ApprovalStatus.APPROVED)}
isLoading={isApproving}
isDisabled={isApproving || hasApproved || !canApprove}
>
{hasApproved ? "Approved" : "Approve"}
</Button>
<Button
size="xs"
colorSchema="danger"
leftIcon={hasRejected && <FontAwesomeIcon icon={faCheck} />}
onClick={() => handleSecretApprovalStatusUpdate(ApprovalStatus.REJECTED)}
isLoading={isRejecting}
isDisabled={isRejecting || hasRejected || !canApprove}
>
{hasRejected ? "Rejected" : "Reject"}
</Button>
</>
)}
</div>
<div className="flex flex-col space-y-4">
{secretApprovalRequestDetails.commits.map(
({ op, secretVersion, secret, ...newVersion }, index) => (
<SecretApprovalRequestChangeItem
op={op}
conflicts={secretApprovalRequestDetails.conflicts}
hasMerged={hasMerged}
secretVersion={secretVersion}
presentSecretVersionNumber={secret?.version || 0}
newVersion={newVersion}
key={`${op}-${index + 1}-${secretVersion?.id}`}
/>
)
)}
</div>
<div className="mt-8 flex items-center space-x-6 rounded-lg bg-mineshaft-800 px-5 py-6">
<SecretApprovalRequestAction
canApprove={canApprove}
approvalRequestId={secretApprovalRequestDetails.id}
hasMerged={hasMerged}
approvals={secretApprovalRequestDetails.policy.approvals || 0}
status={secretApprovalRequestDetails.status}
isMergable={isMergable}
statusChangeByEmail={secretApprovalRequestDetails.statusChangedByUser?.email}
enforcementLevel={secretApprovalRequestDetails.policy.enforcementLevel}
workspaceId={workspaceId}
/>
</div>
</div>
<div className="sticky top-0 w-1/5 pt-4" style={{ minWidth: "240px" }}>
<div className="text-sm text-bunker-300">Reviewers</div>
<div className="mt-2 flex flex-col space-y-2 text-sm">
{secretApprovalRequestDetails?.policy?.approvers.map((requiredApprover) => {
const status = reviewedUsers?.[requiredApprover.userId];
return (
<div
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
key={`required-approver-${requiredApprover.userId}`}
>
<div className="flex-grow text-sm">
<Tooltip
content={`${requiredApprover.firstName || ""} ${
requiredApprover.lastName || ""
}`}
>
<span>{requiredApprover?.email} </span>
</Tooltip>
<span className="text-red">*</span>
</div>
<div>
<Tooltip content={status || ApprovalStatus.PENDING}>
{getReviewedStatusSymbol(status)}
</Tooltip>
</div>
</div>
);
})}
{secretApprovalRequestDetails?.reviewers
.filter(
(reviewer) =>
!secretApprovalRequestDetails?.policy?.approvers?.some(
({ userId }) => userId === reviewer.userId
)
)
.map((reviewer) => {
const status = reviewedUsers?.[reviewer.userId];
return (
<div
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
key={`required-approver-${reviewer.userId}`}
>
<div className="flex-grow text-sm">
<Tooltip content={`${reviewer.firstName || ""} ${reviewer.lastName || ""}`}>
<span>{reviewer?.email} </span>
</Tooltip>
<span className="text-red">*</span>
</div>
<div>
<Tooltip content={status || ApprovalStatus.PENDING}>
{getReviewedStatusSymbol(status)}
</Tooltip>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { SecretApprovalRequest } from "./SecretApprovalRequest";
@@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SecretApprovalsPage } from "./SecretApprovalsPage";
const SecretApprovalPageQueryParams = z.object({
requestId: z.string().catch("")
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/approval"
)({
component: SecretApprovalsPage,
validateSearch: zodValidator(SecretApprovalPageQueryParams)
});
@@ -0,0 +1,616 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability";
import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import NavHeader from "@app/components/navigation/NavHeader";
import { createNotification } from "@app/components/notifications";
import { PermissionDeniedBanner } from "@app/components/permissions";
import {
Checkbox,
ContentLoader,
Modal,
ModalContent,
Pagination,
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
ProjectPermissionActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import {
useGetImportedSecretsSingleEnv,
useGetSecretApprovalPolicyOfABoard,
useGetWorkspaceSnapshotList,
useGetWsSnapshotCount,
useGetWsTags
} from "@app/hooks/api";
import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount";
import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection";
import { ActionBar } from "./components/ActionBar";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { DynamicSecretListView } from "./components/DynamicSecretListView";
import { FolderListView } from "./components/FolderListView";
import { PitDrawer } from "./components/PitDrawer";
import { SecretDropzone } from "./components/SecretDropzone";
import { SecretImportListView } from "./components/SecretImportListView";
import { SecretListView, SecretNoAccessListView } from "./components/SecretListView";
import { SnapshotView } from "./components/SnapshotView";
import {
PopUpNames,
StoreProvider,
usePopUpAction,
usePopUpState,
useSelectedSecretActions,
useSelectedSecrets
} from "./SecretMainPage.store";
import { Filter, RowType } from "./SecretMainPage.types";
const LOADER_TEXT = [
"Retrieving your encrypted secrets...",
"Fetching folders...",
"Getting secret import links..."
];
const Page = () => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const navigate = useNavigate({
from: ROUTE_PATHS.SecretManager.SecretDashboardPage.path
});
const routerQueryParams = useSearch({
from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id
});
const environment = useParams({
from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id,
select: (el) => el.envSlug
});
const { permission } = useProjectPermission();
const [isVisible, setIsVisible] = useState(false);
const {
offset,
limit,
orderDirection,
setOrderDirection,
setPage,
perPage,
page,
setPerPage,
orderBy
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
const [snapshotId, setSnapshotId] = useState<string | null>(null);
const isRollbackMode = Boolean(snapshotId);
const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["snapshots"] as const);
// env slug
const workspaceId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || "";
const secretPath = (routerQueryParams.secretPath as string) || "/";
const canReadSecret = permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})
);
const canReadSecretImports = permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath })
);
const canReadDynamicSecret = permission.can(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })
);
const canDoReadRollback = permission.can(
ProjectPermissionActions.Read,
ProjectPermissionSub.SecretRollback
);
const defaultFilterState = {
tags: {},
searchFilter: (routerQueryParams.search as string) || "",
// these should always be on by default for the UI, they will be disabled for the query below based off permissions
include: {
[RowType.Folder]: true,
[RowType.Import]: true,
[RowType.DynamicSecret]: true,
[RowType.Secret]: true
}
};
const [filter, setFilter] = useState<Filter>(defaultFilterState);
const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(filter.searchFilter);
const [filterHistory, setFilterHistory] = useState<Map<string, Filter>>(new Map());
const createSecretPopUp = usePopUpState(PopUpNames.CreateSecretForm);
const { togglePopUp } = usePopUpAction();
useEffect(() => {
if (!currentWorkspace?.environments.find((env) => env.slug === environment)) {
createNotification({
text: "No environment found with given slug",
type: "error"
});
navigate({
to: `/${ProjectType.SecretManager}/$projectId/overview` as const,
params: {
projectId: workspaceId
}
});
}
}, [currentWorkspace, environment]);
const {
data,
isPending: isDetailsLoading,
isFetching: isDetailsFetching
} = useGetProjectSecretsDetails({
environment,
projectId: workspaceId,
secretPath,
offset,
limit,
orderBy,
search: debouncedSearchFilter,
orderDirection,
includeImports: canReadSecretImports && filter.include.import,
includeFolders: filter.include.folder,
includeDynamicSecrets: canReadDynamicSecret && filter.include.dynamic,
includeSecrets: canReadSecret && filter.include.secret,
tags: filter.tags
});
const {
imports,
folders,
dynamicSecrets,
secrets,
totalImportCount = 0,
totalFolderCount = 0,
totalDynamicSecretCount = 0,
totalSecretCount = 0,
totalCount = 0
} = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
// fetch imported secrets to show user the overriden ones
const { data: importedSecrets } = useGetImportedSecretsSingleEnv({
projectId: workspaceId,
environment,
path: secretPath,
options: {
enabled: canReadSecret
}
});
// fetch tags
const { data: tags } = useGetWsTags(
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : ""
);
const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({
workspaceId,
environment,
secretPath
});
const isProtectedBranch = Boolean(boardPolicy);
const {
data: snapshotList,
isFetchingNextPage: isFetchingNextSnapshotList,
fetchNextPage: fetchNextSnapshotList,
hasNextPage: hasNextSnapshotListPage
} = useGetWorkspaceSnapshotList({
workspaceId,
directory: secretPath,
environment,
isPaused: !popUp.snapshots.isOpen || !canDoReadRollback,
limit: 10
});
const { data: snapshotCount, isPending: isSnapshotCountLoading } = useGetWsSnapshotCount({
workspaceId,
environment,
directory: secretPath,
isPaused: !canDoReadRollback
});
const noAccessSecretCount = Math.max(
(page * perPage > totalCount ? totalCount % perPage : perPage) -
(imports?.length || 0) -
(folders?.length || 0) -
(secrets?.length || 0) -
(dynamicSecrets?.length || 0),
0
);
const isNotEmpty = Boolean(
secrets?.length ||
folders?.length ||
imports?.length ||
dynamicSecrets?.length ||
noAccessSecretCount
);
const handleSortToggle = () =>
setOrderDirection((state) =>
state === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
);
const handleEnvChange = (slug: string) => {
navigate({
params: {
envSlug: slug
},
search: (state) => {
const newState = { ...state, secretPath: undefined };
return newState;
}
});
};
const handleTagToggle = useCallback(
(tagSlug: string) =>
setFilter((state) => {
const isTagPresent = Boolean(state.tags?.[tagSlug]);
const newTagFilter = { ...state.tags };
if (isTagPresent) delete newTagFilter[tagSlug];
else newTagFilter[tagSlug] = true;
return { ...state, tags: newTagFilter };
}),
[]
);
const handleToggleRowType = useCallback(
(rowType: RowType) =>
setFilter((state) => {
return {
...state,
include: {
...state.include,
[rowType]: !state.include[rowType]
}
};
}),
[]
);
const handleSearchChange = useCallback(
(searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })),
[]
);
const handleToggleVisibility = useCallback(() => setIsVisible((state) => !state), []);
// snapshot functions
const handleSelectSnapshot = useCallback((snapId: string) => {
setSnapshotId(snapId);
}, []);
const handleResetSnapshot = useCallback(() => {
setSnapshotId(null);
handlePopUpClose("snapshots");
}, []);
useEffect(() => {
// restore filters for path if set
const restore = filterHistory.get(secretPath);
setFilter(restore ?? defaultFilterState);
setDebouncedSearchFilter(restore?.searchFilter ?? "");
}, [secretPath]);
useEffect(() => {
if (!routerQueryParams.search && !routerQueryParams.tags) return;
const queryTags = routerQueryParams.tags
? (routerQueryParams.tags as string).split(",").filter((tag) => Boolean(tag.trim()))
: [];
const updatedTags: Record<string, boolean> = {};
queryTags.forEach((tag) => {
updatedTags[tag] = true;
});
setFilter((prev) => ({
...prev,
...defaultFilterState,
searchFilter: (routerQueryParams.search as string) ?? "",
tags: updatedTags
}));
setDebouncedSearchFilter(routerQueryParams.search as string);
// this is a temp workaround until we fully transition state to query params,
navigate({
search: (state) => {
const { search, tags: qTags, ...query } = state;
return query;
}
});
}, [routerQueryParams.search, routerQueryParams.tags]);
const selectedSecrets = useSelectedSecrets();
const selectedSecretActions = useSelectedSecretActions();
const allRowsSelectedOnPage = useMemo(() => {
if (secrets?.every((secret) => selectedSecrets[secret.id]))
return { isChecked: true, isIndeterminate: false };
if (secrets?.some((secret) => selectedSecrets[secret.id]))
return { isChecked: true, isIndeterminate: true };
return { isChecked: false, isIndeterminate: false };
}, [selectedSecrets, secrets]);
const toggleSelectAllRows = () => {
const newChecks = { ...selectedSecrets };
secrets?.forEach((secret) => {
if (allRowsSelectedOnPage.isChecked) {
delete newChecks[secret.id];
} else {
newChecks[secret.id] = secret;
}
});
selectedSecretActions.set(newChecks);
};
if (isDetailsLoading) {
return <ContentLoader text={LOADER_TEXT} />;
}
const handleResetFilter = () => {
// store for breadcrumb nav to restore previously used filters
setFilterHistory((prev) => {
const curr = new Map(prev);
curr.set(secretPath, filter);
return curr;
});
setFilter(defaultFilterState);
setDebouncedSearchFilter("");
};
return (
<div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
<div className="relative -top-2 right-6 mb-2 ml-6">
<NavHeader
pageName={t("dashboard.title")}
currentEnv={environment}
userAvailableEnvs={currentWorkspace?.environments}
isFolderMode
secretPath={secretPath}
isProjectRelated
onEnvChange={handleEnvChange}
isProtectedBranch={isProtectedBranch}
protectionPolicyName={boardPolicy?.name}
/>
</div>
{!isRollbackMode ? (
<>
<ActionBar
environment={environment}
workspaceId={workspaceId}
projectSlug={projectSlug}
secretPath={secretPath}
isVisible={isVisible}
filter={filter}
tags={tags}
onVisibilityToggle={handleToggleVisibility}
onSearchChange={handleSearchChange}
onToggleTagFilter={handleTagToggle}
snapshotCount={snapshotCount || 0}
isSnapshotCountLoading={isSnapshotCountLoading}
onToggleRowType={handleToggleRowType}
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
/>
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
<div className="flex flex-col" id="dashboard">
{isNotEmpty && (
<div
className={twMerge(
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
)}
>
<Tooltip
className="max-w-[20rem] whitespace-nowrap"
content={
totalCount > 0
? `${
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
} all secrets on page`
: ""
}
>
<div className="mr-[0.055rem] flex w-11 items-center justify-center pl-2.5">
<Checkbox
isDisabled={totalCount === 0}
id="checkbox-select-all-rows"
onClick={(e) => e.stopPropagation()}
isChecked={allRowsSelectedOnPage.isChecked}
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
onCheckedChange={toggleSelectAllRows}
/>
</div>
</Tooltip>
<div
className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 py-2 pl-4"
role="button"
tabIndex={0}
onClick={handleSortToggle}
onKeyDown={(evt) => {
if (evt.key === "Enter") handleSortToggle();
}}
>
Key
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
className="ml-2"
/>
</div>
<div className="flex-grow px-4 py-2">Value</div>
</div>
)}
{canReadSecretImports && Boolean(imports?.length) && (
<SecretImportListView
searchTerm={debouncedSearchFilter}
secretImports={imports}
isFetching={isDetailsFetching}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
importedSecrets={importedSecrets}
/>
)}
{Boolean(folders?.length) && (
<FolderListView
folders={folders}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
onNavigateToFolder={handleResetFilter}
/>
)}
{canReadDynamicSecret && Boolean(dynamicSecrets?.length) && (
<DynamicSecretListView
environment={environment}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecrets={dynamicSecrets}
/>
)}
{canReadSecret && Boolean(secrets?.length) && (
<SecretListView
secrets={secrets}
tags={tags}
isVisible={isVisible}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isProtectedBranch={isProtectedBranch}
/>
)}
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />}
{!canReadSecret &&
!canReadDynamicSecret &&
!canReadSecretImports &&
folders?.length === 0 && <PermissionDeniedBanner />}
</div>
</div>
{!isDetailsLoading && totalCount > 0 && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
importCount={totalImportCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
/>
}
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
<Modal
isOpen={createSecretPopUp.isOpen}
onOpenChange={(state) => togglePopUp(PopUpNames.CreateSecretForm, state)}
>
<ModalContent
title="Create Secret"
subTitle="Add a secret to this particular environment and folder"
bodyClassName="overflow-visible"
>
<CreateSecretForm
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
autoCapitalize={currentWorkspace?.autoCapitalization}
isProtectedBranch={isProtectedBranch}
/>
</ModalContent>
</Modal>
<SecretDropzone
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isSmaller={isNotEmpty}
environments={currentWorkspace?.environments}
isProtectedBranch={isProtectedBranch}
/>
<PitDrawer
secretSnaphots={snapshotList}
snapshotId={snapshotId}
isDrawerOpen={popUp.snapshots.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("snapshots", isOpen)}
hasNextPage={hasNextSnapshotListPage}
fetchNextPage={fetchNextSnapshotList}
onSelectSnapshot={handleSelectSnapshot}
isFetchingNextPage={isFetchingNextSnapshotList}
/>
</>
) : (
<SnapshotView
snapshotId={snapshotId || ""}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
secrets={secrets}
folders={folders}
snapshotCount={snapshotCount}
onGoBack={handleResetSnapshot}
onClickListSnapshot={() => handlePopUpToggle("snapshots", true)}
/>
)}
</div>
);
};
export const SecretDashboardPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("dashboard.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={String(t("dashboard.og-title"))} />
<meta name="og:description" content={String(t("dashboard.og-description"))} />
</Helmet>
<div className="h-full">
<StoreProvider>
<Page />
</StoreProvider>
</div>
</>
);
};
@@ -0,0 +1,101 @@
import { createContext, ReactNode, useContext, useEffect, useRef } from "react";
import { useRouter } from "@tanstack/react-router";
import { createStore, StateCreator, StoreApi, useStore } from "zustand";
import { useShallow } from "zustand/react/shallow";
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
// akhilmhdh: Don't remove this file if ur thinking why use zustand just for selected selects state
// This is first step and the whole secret crud will be moved to this global page scope state
// this will allow more stuff like undo grouping stuffs etc
type SelectedSecretState = {
selectedSecret: Record<string, SecretV3RawSanitized>;
action: {
toggle: (secret: SecretV3RawSanitized) => void;
reset: () => void;
set: (secrets: Record<string, SecretV3RawSanitized>) => void;
};
};
const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
selectedSecret: {},
action: {
toggle: (secret) =>
set((state) => {
const isChecked = Boolean(state.selectedSecret?.[secret.id]);
const newChecks = { ...state.selectedSecret };
// remove selection if its present else add it
if (isChecked) delete newChecks[secret.id];
else newChecks[secret.id] = secret;
return { selectedSecret: newChecks };
}),
reset: () => set({ selectedSecret: {} }),
set: (secrets) => set({ selectedSecret: secrets })
}
});
export enum PopUpNames {
CreateSecretForm = "create-secret-form"
}
type PopUpState = {
popUp: Record<string, { isOpen: boolean; data?: any }>;
popUpActions: {
togglePopUp: (id: PopUpNames, isOpen?: boolean) => void;
closePopUp: (id: PopUpNames) => void;
openPopUp: (id: PopUpNames, data?: any) => void;
};
};
const createPopUpStore: StateCreator<PopUpState> = (set) => ({
popUp: {},
popUpActions: {
closePopUp: (id) => set((state) => ({ popUp: { ...state.popUp, [id]: { isOpen: false } } })),
openPopUp: (id, data) =>
set((state) => ({ popUp: { ...state.popUp, [id]: { isOpen: true, data } } })),
togglePopUp: (id, isOpen) =>
set((state) => ({
popUp: { ...state.popUp, [id]: { isOpen: isOpen ?? !state.popUp[id].isOpen } }
}))
}
});
type CombinedState = SelectedSecretState & PopUpState;
const StoreContext = createContext<StoreApi<CombinedState> | null>(null);
export const StoreProvider = ({ children }: { children: ReactNode }) => {
const storeRef = useRef<StoreApi<CombinedState>>(
createStore<CombinedState>((...a) => ({
...createSelectedSecretStore(...a),
...createPopUpStore(...a)
}))
);
const router = useRouter();
useEffect(() => {
const onRouteChangeStart = () => {
const state = storeRef.current?.getState();
state?.action.reset();
};
const unsubscribe = router.subscribe("onBeforeLoad", onRouteChangeStart);
return () => {
unsubscribe();
};
}, []);
return <StoreContext.Provider value={storeRef.current}>{children}</StoreContext.Provider>;
};
const useStoreContext = <T extends object>(selector: (state: CombinedState) => T): T => {
const ctx = useContext(StoreContext);
if (!ctx) throw new Error("Missing ");
return useStore(ctx, selector);
};
// selected secret context
export const useSelectedSecrets = () =>
useStoreContext(useShallow((state) => state.selectedSecret));
export const useSelectedSecretActions = () => useStoreContext(useShallow((state) => state.action));
// popup context
export const usePopUpState = (id: PopUpNames) =>
useStoreContext(useShallow((state) => state.popUp?.[id] || { isOpen: false }));
export const usePopUpAction = () => useStoreContext(useShallow((state) => state.popUpActions));
@@ -0,0 +1,14 @@
export type Filter = {
tags: Record<string, boolean>;
searchFilter: string;
include: {
[key in RowType]: boolean;
};
};
export enum RowType {
Folder = "folder",
Import = "import",
DynamicSecret = "dynamic",
Secret = "secret"
}
@@ -0,0 +1,662 @@
import { TypeOptions } from "react-toastify";
import { subject } from "@casl/ability";
import {
faAngleDown,
faAnglesRight,
faCheckCircle,
faChevronRight,
faCodeCommit,
faDownload,
faEye,
faEyeSlash,
faFileImport,
faFilter,
faFingerprint,
faFolder,
faFolderPlus,
faKey,
faMinusSquare,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import FileSaver from "file-saver";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
IconButton,
Modal,
ModalContent,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionSub,
useSubscription,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
import { SecretType, WsTag } from "@app/hooks/api/types";
import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput";
import {
PopUpNames,
usePopUpAction,
useSelectedSecretActions,
useSelectedSecrets
} from "../../SecretMainPage.store";
import { Filter, RowType } from "../../SecretMainPage.types";
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm";
import { FolderForm } from "./FolderForm";
import { MoveSecretsModal } from "./MoveSecretsModal";
type Props = {
// switch the secrets type as it gets decrypted after api call
environment: string;
// @depreciated will be moving all these details to zustand
workspaceId: string;
projectSlug: string;
secretPath?: string;
filter: Filter;
tags?: WsTag[];
isVisible?: boolean;
snapshotCount: number;
isSnapshotCountLoading?: boolean;
onSearchChange: (term: string) => void;
onToggleTagFilter: (tagId: string) => void;
onVisibilityToggle: () => void;
onToggleRowType: (rowType: RowType) => void;
onClickRollbackMode: () => void;
};
export const ActionBar = ({
environment,
workspaceId,
projectSlug,
secretPath = "/",
filter,
tags = [],
isVisible,
snapshotCount,
isSnapshotCountLoading,
onSearchChange,
onToggleTagFilter,
onVisibilityToggle,
onClickRollbackMode,
onToggleRowType
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder",
"addDynamicSecret",
"addSecretImport",
"bulkDeleteSecrets",
"moveSecrets",
"misc",
"upgradePlan"
] as const);
const { subscription } = useSubscription();
const { openPopUp } = usePopUpAction();
const { mutateAsync: createFolder } = useCreateFolder();
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
const { mutateAsync: moveSecrets } = useMoveSecrets();
const selectedSecrets = useSelectedSecrets();
const { reset: resetSelectedSecret } = useSelectedSecretActions();
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
const { currentWorkspace } = useWorkspace();
const handleFolderCreate = async (folderName: string) => {
try {
await createFolder({
name: folderName,
path: secretPath,
environment,
projectId: workspaceId
});
handlePopUpClose("addFolder");
createNotification({
type: "success",
text: "Successfully created folder"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to create folder"
});
}
};
const handleSecretDownload = async () => {
const { secrets: localSecrets, imports: localImportedSecrets } = await fetchProjectSecrets({
workspaceId,
expandSecretReferences: true,
includeImports: true,
environment,
secretPath
});
const secretsPicked = new Set<string>();
const secretsToDownload: { key: string; value?: string; comment?: string }[] = [];
localSecrets.forEach((el) => {
secretsPicked.add(el.secretKey);
secretsToDownload.push({
key: el.secretKey,
value: el.secretValue,
comment: el.secretComment
});
});
for (let i = localImportedSecrets.length - 1; i >= 0; i -= 1) {
for (let j = localImportedSecrets[i].secrets.length - 1; j >= 0; j -= 1) {
const secret = localImportedSecrets[i].secrets[j];
if (!secretsPicked.has(secret.secretKey)) {
secretsToDownload.push({
key: secret.secretKey,
value: secret.secretValue,
comment: secret.secretComment
});
}
secretsPicked.add(secret.secretKey);
}
}
const file = secretsToDownload
.sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase()))
.reduce(
(prev, { key, comment, value }, index) =>
prev +
(comment
? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n`
: `${key}=${value}\n`),
""
);
const blob = new Blob([file], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, `${environment}.env`);
};
const handleSecretBulkDelete = async () => {
const bulkDeletedSecrets = Object.values(selectedSecrets);
try {
await deleteBatchSecretV3({
secretPath,
workspaceId,
environment,
secrets: bulkDeletedSecrets.map(({ key }) => ({ secretKey: key, type: SecretType.Shared }))
});
resetSelectedSecret();
handlePopUpClose("bulkDeleteSecrets");
createNotification({
type: "success",
text: "Successfully deleted secrets"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to delete secrets"
});
}
};
const handleSecretsMove = async ({
destinationEnvironment,
destinationSecretPath,
shouldOverwrite
}: {
destinationEnvironment: string;
destinationSecretPath: string;
shouldOverwrite: boolean;
}) => {
try {
const secretsToMove = Object.values(selectedSecrets);
const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({
projectSlug,
shouldOverwrite,
sourceEnvironment: environment,
sourceSecretPath: secretPath,
destinationEnvironment,
destinationSecretPath,
projectId: workspaceId,
secretIds: secretsToMove.map((sec) => sec.id)
});
let notificationMessage = "";
let notificationType: TypeOptions = "info";
if (isDestinationUpdated && isSourceUpdated) {
notificationMessage = "Successfully moved selected secrets";
notificationType = "success";
} else if (isDestinationUpdated) {
notificationMessage =
"Successfully created secrets in destination. A secret approval request has been generated for the source.";
} else if (isSourceUpdated) {
notificationMessage = "A secret approval request has been generated in the destination";
} else {
notificationMessage =
"A secret approval request has been generated in both the source and the destination.";
}
createNotification({
type: notificationType,
text: notificationMessage
});
resetSelectedSecret();
} catch (error) {
console.error(error);
}
};
return (
<>
<div className="mt-4 flex items-center space-x-2">
<SecretSearchInput
isSingleEnv
className="w-2/5"
value={filter.searchFilter}
onChange={onSearchChange}
environments={[currentWorkspace.environments.find((env) => env.slug === environment)!]}
projectId={workspaceId}
tags={tags}
/>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
variant="outline_bg"
ariaLabel="Download"
className={twMerge(
"transition-all",
(Object.keys(filter.tags).length ||
Object.values(filter.include).filter((include) => !include).length) &&
"border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0">
<DropdownMenuGroup>Filter By</DropdownMenuGroup>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
onToggleRowType(RowType.Import);
}}
icon={filter?.include[RowType.Import] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFileImport} className="text-green-700" />
<span>Imports</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
onToggleRowType(RowType.Folder);
}}
icon={filter?.include[RowType.Folder] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
<span>Folders</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
onToggleRowType(RowType.DynamicSecret);
}}
icon={
filter?.include[RowType.DynamicSecret] && <FontAwesomeIcon icon={faCheckCircle} />
}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFingerprint} className="text-yellow-700" />
<span>Dynamic Secrets</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
onToggleRowType(RowType.Secret);
}}
icon={filter?.include[RowType.Secret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faKey} className="text-bunker-300" />
<span>Secrets</span>
</div>
</DropdownMenuItem>
<DropdownSubMenu>
<DropdownSubMenuTrigger
iconPos="right"
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
>
Tags
</DropdownSubMenuTrigger>
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Apply Tags to Filter Secrets
</DropdownMenuLabel>
{tags.map(({ id, slug, color }) => (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onToggleTagFilter(slug);
}}
key={id}
icon={filter?.tags[slug] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: color || "#bec2c8" }}
/>
{slug}
</div>
</DropdownMenuItem>
))}
</DropdownSubMenuContent>
</DropdownSubMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex-grow" />
<div>
<IconButton variant="outline_bg" ariaLabel="Download" onClick={handleSecretDownload}>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</div>
<div>
<IconButton variant="outline_bg" ariaLabel="Reveal" onClick={onVisibilityToggle}>
<FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} />
</IconButton>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SecretRollback}
>
{(isAllowed) => (
<Button
variant="outline_bg"
onClick={() => {
if (subscription && subscription.pitRecovery) {
onClickRollbackMode();
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
isLoading={isSnapshotCountLoading}
className="h-10"
isDisabled={!isAllowed}
>
{`${snapshotCount} ${snapshotCount === 1 ? "Snapshot" : "Snapshots"}`}
</Button>
)}
</ProjectPermissionCan>
</div>
<div className="flex items-center">
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
>
{(isAllowed) => (
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => openPopUp(PopUpNames.CreateSecretForm)}
className="h-10 rounded-r-none"
isDisabled={!isAllowed}
>
Add Secret
</Button>
)}
</ProjectPermissionCan>
<DropdownMenu
open={popUp.misc.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="add-folder-or-import"
variant="outline_bg"
className="rounded-l-none bg-mineshaft-600 p-3"
>
<FontAwesomeIcon icon={faAngleDown} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="flex flex-col space-y-1 p-1.5">
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.SecretFolders, {
environment,
secretPath
})}
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFolderPlus} className="pr-2" />}
onClick={() => {
handlePopUpOpen("addFolder");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add Folder
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.CreateRootCredential}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFingerprint} className="pr-2" />}
onClick={() => {
if (subscription && subscription.dynamicSecret) {
handlePopUpOpen("addDynamicSecret");
handlePopUpClose("misc");
return;
}
handlePopUpOpen("upgradePlan");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add Dynamic Secret
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.SecretImports, {
environment,
secretPath
})}
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFileImport} className="pr-2" />}
onClick={() => {
handlePopUpOpen("addSecretImport");
handlePopUpClose("misc");
}}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
isDisabled={!isAllowed}
>
Add Import
</Button>
)}
</ProjectPermissionCan>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
className={twMerge(
"h-0 flex-shrink-0 overflow-hidden transition-all",
isMultiSelectActive && "h-16"
)}
>
<div className="mt-3.5 flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-bunker-300">
<Tooltip content="Clear">
<IconButton variant="plain" ariaLabel="clear-selection" onClick={resetSelectedSecret}>
<FontAwesomeIcon icon={faMinusSquare} size="lg" />
</IconButton>
</Tooltip>
<div className="ml-2 flex-grow px-2 text-sm">
{Object.keys(selectedSecrets).length} Selected
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
renderTooltip
allowedLabel="Move"
>
{(isAllowed) => (
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faAnglesRight} />}
className="ml-4"
onClick={() => handlePopUpOpen("moveSecrets")}
isDisabled={!isAllowed}
size="xs"
>
Move
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<Button
variant="outline_bg"
colorSchema="danger"
leftIcon={<FontAwesomeIcon icon={faTrash} />}
className="ml-2"
onClick={() => handlePopUpOpen("bulkDeleteSecrets")}
isDisabled={!isAllowed}
size="xs"
>
Delete
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
{/* all the side triggers from actions like modals etc */}
<CreateSecretImportForm
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
onUpgradePlan={() => handlePopUpOpen("upgradePlan")}
isOpen={popUp.addSecretImport.isOpen}
onClose={() => handlePopUpClose("addSecretImport")}
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
/>
<CreateDynamicSecretForm
isOpen={popUp.addDynamicSecret.isOpen}
onToggle={(isOpen) => handlePopUpToggle("addDynamicSecret", isOpen)}
projectSlug={projectSlug}
environment={environment}
secretPath={secretPath}
/>
<Modal
isOpen={popUp.addFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
>
<ModalContent title="Create Folder">
<FolderForm onCreateFolder={handleFolderCreate} />
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.bulkDeleteSecrets.isOpen}
deleteKey="delete"
title="Do you want to delete these secrets?"
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
onDeleteApproved={handleSecretBulkDelete}
/>
<MoveSecretsModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
onMoveApproved={handleSecretsMove}
/>
{subscription && (
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={
subscription.slug === null
? "You can perform this action under an Enterprise license"
: "You can perform this action if you switch to Infisical's Team plan"
}
/>
)}
</>
);
};
ActionBar.displayName = "ActionBar";
@@ -0,0 +1,317 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
SecretInput,
TextArea
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
clusterName: z.string().trim().min(1),
accessKeyId: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
region: z.string().trim(),
creationStatement: z.string().trim(),
revocationStatement: z.string().trim(),
ca: z.string().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const AwsElastiCacheInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
creationStatement: `{
"UserId": "{{username}}",
"UserName": "{{username}}",
"Engine": "redis",
"Passwords": ["{{password}}"],
"AccessString": "on ~* +@all"
}`,
revocationStatement: `{
"UserId": "{{username}}"
}`
}
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.AwsElastiCache, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.clusterName"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster name"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="redis-oss-cluster" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="provider.region"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Region"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input placeholder="us-east-1" {...field} type="text" />
</FormControl>
)}
/>
</div>
<div className="flex w-full items-center space-x-2">
<Controller
control={control}
name="provider.accessKeyId"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Key ID"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.secretAccessKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Access Key"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify ElastiCache Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,303 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
accessKey: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
permissionBoundaryPolicyArn: z.string().trim().optional(),
policyDocument: z.string().trim().optional(),
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const AwsIamInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.AwsIam, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-postgres" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.accessKey"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Access Key"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.secretAccessKey"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Secret Key"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" />
</FormControl>
)}
/>
</div>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.awsPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Path"
className="flex-grow"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.region"
defaultValue="us-east-1"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Region"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="provider.permissionBoundaryPolicyArn"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="IAM User Permission Boundary ARN"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="ARN to be attached to the generated user for AWS Permission Boundary."
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.userGroups"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Groups"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given groups."
>
<Input {...field} placeholder="group1,group2" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.policyArns"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Policy ARNs"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given policy arns."
>
<Input
{...field}
placeholder="arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.policyDocument"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Policy Document"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Generated users will have the inline policy."
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,388 @@
import { Controller, useForm } from "react-hook-form";
import {
faArrowUpRightFromSquare,
faBookOpen,
faCheckCircle,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/v2/Dropdown/Dropdown";
import { Tooltip } from "@app/components/v2/Tooltip";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { useGetDynamicSecretProviderData } from "@app/hooks/api/dynamicSecret/queries";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
selectedUsers: z.array(
z.object({
id: z.string().min(1),
name: z.string().min(1),
email: z.string().min(1)
})
),
provider: z.object({
tenantId: z.string().min(1),
applicationId: z.string().min(1),
clientSecret: z.string().min(1)
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z
.string()
.min(1)
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const AzureEntraIdInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
watch,
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const tenantId = watch("provider.tenantId");
const applicationId = watch("provider.applicationId");
const clientSecret = watch("provider.clientSecret");
const configurationComplete = !!(tenantId && applicationId && clientSecret);
const { data, isLoading, isError, isFetching } = useGetDynamicSecretProviderData({
tenantId,
applicationId,
clientSecret,
enabled: !!configurationComplete
});
const loading = configurationComplete && isFetching;
const errored = configurationComplete && !isFetching && isError;
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({
name,
selectedUsers,
provider,
maxTTL,
defaultTTL
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
selectedUsers.map(async (user: { id: string; name: string; email: string }) => {
await createDynamicSecret.mutateAsync({
provider: {
type: DynamicSecretProviders.AzureEntraId,
inputs: {
userId: user.id,
tenantId: provider.tenantId,
email: user.email,
applicationId: provider.applicationId,
clientSecret: provider.clientSecret
}
},
maxTTL,
name: `${name}-${user.name}`,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Prefix"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/dynamic-secrets/azure-entra-id"
>
<div className="mb-1 ml-2 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-xxs"
/>
</div>
</a>
</div>
<div className="flex flex-col">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="provider.tenantId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Tenant Id"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="Tenant Id from Azure Entra ID App installation"
/>
</FormControl>
)}
/>
</div>
</div>
<div className="flex flex-col">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="provider.applicationId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Application Id"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="Application ID from Azure Entra ID App installation"
/>
</FormControl>
)}
/>
</div>
</div>
<div className="flex flex-col">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="provider.clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="Client Secret from Azure Entra ID App installation"
/>
</FormControl>
)}
/>
</div>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Select Users
</div>
<div className="mb-4 flex items-center text-sm font-normal text-mineshaft-400">
&nbsp; We create a unique dynamic secret for each user in Entra Id.
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-4">
<Controller
control={control}
name="selectedUsers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isRequired isError={Boolean(error)} errorText={error?.message}>
<DropdownMenu>
<DropdownMenuTrigger
className="w-72"
disabled={loading || errored || !configurationComplete}
>
<Tooltip
hidden={!loading && !errored && configurationComplete}
content={
<div>
{(() => {
let icon;
if (errored) {
icon = <FontAwesomeIcon icon={faWarning} color="red" />;
} else if (loading || !configurationComplete) {
icon = <FontAwesomeIcon icon={faWarning} color="yellow" />;
} else {
icon = null;
}
return icon;
})()}
<span className="ml-4 cursor-default text-mineshaft-300 hover:text-mineshaft-200">
{(() => {
let message;
if (loading) {
message = "Loading, please wait...";
} else if (errored) {
message = "Check the configuration";
} else if (!configurationComplete) {
message = "Configuration incomplete";
} else {
message = ""; // or you can leave it undefined
}
return message;
})()}
</span>
</div>
}
>
<div>
<Input
isReadOnly
value={value?.length ? `${value.length} selected` : ""}
className={`text-left ${loading || errored || !configurationComplete ? "cursor-not-allowed" : ""}`}
placeholder="Select users"
/>
</div>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
>
{data &&
data.map((user) => {
const ids = value?.map((selectedUser) => selectedUser.id);
const isChecked = ids?.includes(user.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el) => el.id !== user.id)
: [...(value || []), user]
);
}}
key={`create-policy-members-${user.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.name} <br /> {`(${user.email})`}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isLoading || isError}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,363 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
SecretInput,
TextArea
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
host: z.string().toLowerCase().min(1),
port: z.coerce.number(),
keyspace: z.string().optional(),
localDataCenter: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
creationStatement: z.string().min(1),
revocationStatement: z.string().min(1),
renewStatement: z.string().optional(),
ca: z.string().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
const getSqlStatements = () => {
return {
creationStatement:
"CREATE ROLE '{{username}}' WITH PASSWORD = '{{password}}' AND LOGIN=true;\nGRANT ALL PERMISSIONS ON ALL KEYSPACES TO '{{username}}';",
renewStatement: "",
revocationStatement: 'DROP ROLE "{{username}}";'
};
};
export const CassandraInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: getSqlStatements()
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Cassandra, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-postgres" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="host1,host2" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={9042}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="provider.localDataCenter"
defaultValue="datacenter1"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Local Data Center"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.keyspace"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Keyspace"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify CQL Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="variables: keyspace. username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="variables: keyspace, username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.renewStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="variables: keyspace, username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,461 @@
import { useState } from "react";
import { DiRedis } from "react-icons/di";
import {
SiApachecassandra,
SiElasticsearch,
SiFiles,
SiMongodb,
SiRabbitmq,
SiSap,
SiSnowflake
} from "react-icons/si";
import { VscAzure } from "react-icons/vsc";
import { faAws } from "@fortawesome/free-brands-svg-icons";
import { faClock, faDatabase } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
import { AwsIamInputForm } from "./AwsIamInputForm";
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
import { CassandraInputForm } from "./CassandraInputForm";
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
import { LdapInputForm } from "./LdapInputForm";
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
import { MongoDBDatabaseInputForm } from "./MongoDBInputForm";
import { RabbitMqInputForm } from "./RabbitMqInputForm";
import { RedisInputForm } from "./RedisInputForm";
import { SapAseInputForm } from "./SapAseInputForm";
import { SapHanaInputForm } from "./SapHanaInputForm";
import { SnowflakeInputForm } from "./SnowflakeInputForm";
import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm";
import { TotpInputForm } from "./TotpInputForm";
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
projectSlug: string;
environment: string;
secretPath: string;
};
enum WizardSteps {
SelectProvider = "select-provider",
ProviderInputs = "provider-inputs"
}
const DYNAMIC_SECRET_LIST = [
{
icon: <FontAwesomeIcon icon={faDatabase} size="lg" />,
provider: DynamicSecretProviders.SqlDatabase,
title: "SQL\nDatabase"
},
{
icon: <SiApachecassandra size="2rem" />,
provider: DynamicSecretProviders.Cassandra,
title: "Cassandra"
},
{
icon: <DiRedis size="2rem" />,
provider: DynamicSecretProviders.Redis,
title: "Redis"
},
{
icon: <FontAwesomeIcon icon={faAws} size="lg" />,
provider: DynamicSecretProviders.AwsElastiCache,
title: "AWS ElastiCache"
},
{
icon: <FontAwesomeIcon icon={faAws} size="lg" />,
provider: DynamicSecretProviders.AwsIam,
title: "AWS IAM"
},
{
icon: <SiMongodb size="2rem" />,
provider: DynamicSecretProviders.MongoAtlas,
title: "Mongo Atlas"
},
{
icon: <SiMongodb size="2rem" />,
provider: DynamicSecretProviders.MongoDB,
title: "Mongo DB"
},
{
icon: <SiElasticsearch size="2rem" />,
provider: DynamicSecretProviders.ElasticSearch,
title: "Elastic Search"
},
{
icon: <SiRabbitmq size="1.5rem" />,
provider: DynamicSecretProviders.RabbitMq,
title: "RabbitMQ"
},
{
icon: <VscAzure size="1.5rem" />,
provider: DynamicSecretProviders.AzureEntraId,
title: "Azure Entra ID"
},
{
icon: <SiFiles size="1.5rem" />,
provider: DynamicSecretProviders.Ldap,
title: "LDAP"
},
{
icon: <SiSap size="1.5rem" />,
provider: DynamicSecretProviders.SapHana,
title: "SAP HANA"
},
{
icon: <SiSap size="1.5rem" />,
provider: DynamicSecretProviders.SapAse,
title: "SAP ASE"
},
{
icon: <SiSnowflake size="1.5rem" />,
provider: DynamicSecretProviders.Snowflake,
title: "Snowflake"
},
{
icon: <FontAwesomeIcon icon={faClock} size="lg" />,
provider: DynamicSecretProviders.Totp,
title: "TOTP"
}
];
export const CreateDynamicSecretForm = ({
isOpen,
onToggle,
projectSlug,
environment,
secretPath
}: Props) => {
const [wizardStep, setWizardStep] = useState(WizardSteps.SelectProvider);
const [selectedProvider, setSelectedProvider] = useState<DynamicSecretProviders | null>(null);
const handleFormReset = (state: boolean = false) => {
onToggle(state);
setWizardStep(WizardSteps.SelectProvider);
setSelectedProvider(null);
};
return (
<Modal isOpen={isOpen} onOpenChange={(state) => handleFormReset(state)}>
<ModalContent
title="Dynamic secret setup"
subTitle="Configure dynamic secret parameters"
className="my-4 max-w-3xl"
>
<AnimatePresence mode="wait">
{wizardStep === WizardSteps.SelectProvider && (
<motion.div
key="select-type-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<div className="mb-4 text-mineshaft-300">Select a service to connect to:</div>
<div className="flex flex-wrap items-center gap-4">
{DYNAMIC_SECRET_LIST.map(({ icon, provider, title }) => (
<div
key={`dynamic-secret-provider-${provider}`}
className="flex h-32 w-32 cursor-pointer flex-col items-center space-y-4 rounded border border-mineshaft-500 bg-bunker-600 p-6 transition-all hover:border-primary/70 hover:bg-primary/10 hover:text-white"
role="button"
tabIndex={0}
onClick={() => {
setSelectedProvider(provider);
setWizardStep(WizardSteps.ProviderInputs);
}}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
setSelectedProvider(provider);
setWizardStep(WizardSteps.ProviderInputs);
}
}}
>
{icon}
<div className="whitespace-pre-wrap text-center text-sm">{title}</div>
</div>
))}
</div>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.SqlDatabase && (
<motion.div
key="dynamic-sql-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<SqlDatabaseInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Redis && (
<motion.div
key="dynamic-redis-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<RedisInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.AwsElastiCache && (
<motion.div
key="dynamic-aws-elasticache-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<AwsElastiCacheInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Cassandra && (
<motion.div
key="dynamic-cassandra-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<CassandraInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.AwsIam && (
<motion.div
key="dynamic-aws-iam-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<AwsIamInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.MongoAtlas && (
<motion.div
key="dynamic-atlas-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<MongoAtlasInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.ElasticSearch && (
<motion.div
key="dynamic-elastic-search-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<ElasticSearchInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.MongoDB && (
<motion.div
key="dynamic-mongodb-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<MongoDBDatabaseInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.RabbitMq && (
<motion.div
key="dynamic-rabbit-mq-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<RabbitMqInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.AzureEntraId && (
<motion.div
key="dynamic-azure-entra-id-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<AzureEntraIdInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Ldap && (
<motion.div
key="dynamic-ldap-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<LdapInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.SapHana && (
<motion.div
key="dynamic-sap-hana-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<SapHanaInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.SapAse && (
<motion.div
key="dynamic-sap-ase-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
>
<SapAseInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Snowflake && (
<motion.div
key="dynamic-snowflake-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<SnowflakeInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Totp && (
<motion.div
key="dynamic-totp-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<TotpInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,423 @@
import { Controller, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
FormLabel,
IconButton,
Input,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const authMethods = [
{
label: "Username/Password",
value: "user"
},
{
label: "API Key",
value: "api-key"
}
] as const;
const formSchema = z.object({
provider: z.object({
host: z.string().trim().min(1),
port: z.coerce.number(),
// two auth types "user, apikey"
auth: z.discriminatedUnion("type", [
z.object({
type: z.literal("user"),
username: z.string().trim(),
password: z.string().trim()
}),
z.object({
type: z.literal("api-key"),
apiKey: z.string().trim(),
apiKeyId: z.string().trim()
})
]),
roles: z.array(z.string().trim().min(1)).min(1, "At least one role is required"),
ca: z.string().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const ElasticSearchInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit,
setValue,
watch
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
auth: {
type: "user"
},
roles: ["superuser"],
port: 443
}
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.ElasticSearch, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
const selectedAuthType = watch("provider.auth.type");
const selectedRoles = watch("provider.roles");
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
placeholder="https://fgy543ws2w35dfh7jdaafa12ha.aws-us-east-1.io"
{...field}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={443}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<div className="flex w-full items-center gap-2">
<Controller
control={control}
name="provider.auth.type"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Authentication Method"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(e) => {
const authFields = [
"provider.auth.username",
"provider.auth.password",
"provider.auth.apiKey",
"provider.auth.apiKeyId"
] as const;
authFields.forEach((f) => {
setValue(f, "");
});
field.onChange(e);
}}
>
{authMethods.map((authType) => (
<SelectItem value={authType.value} key={`auth-method-${authType.value}`}>
{authType.label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={
selectedAuthType === "user"
? "provider.auth.username"
: "provider.auth.apiKeyId"
}
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={selectedAuthType === "user" ? "Username" : "API Key ID"}
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name={
selectedAuthType === "user" ? "provider.auth.password" : "provider.auth.apiKey"
}
render={({ field, fieldState: { error } }) => (
<FormControl
label={selectedAuthType === "user" ? "Password" : "API Key"}
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div className="mb-3 flex flex-col">
<FormLabel
className="mb-2"
label="Roles"
tooltipText={
<div className="space-y-4">
<p>Select which role(s) to assign the users provisioned by Infisical.</p>
<p>
There is a wide range of in-built roles in Elastic Search. Some include,
superuser, apm_user, kibana_admin, monitoring_user, and many more. You can{" "}
<a
href="https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-roles.html"
target="_blank"
rel="noopener noreferrer"
>
<span className="cursor-pointer text-primary-400">
read more about roles here
</span>
</a>
.
</p>
<p>
You can also assign custom roles by providing the name of the custom role in
the input field.
</p>
</div>
}
/>
<div className="flex flex-col -space-y-2">
{selectedRoles.map((_, i) => (
<Controller
control={control}
name={`provider.roles.${i}`}
// eslint-disable-next-line react/no-array-index-key
key={`role-${i}`}
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<div className="flex h-9 items-center gap-2">
<Input
placeholder="Insert role name, (superuser, kibana_admin, custom_role)"
className="mb-0 flex-grow"
{...field}
/>
<IconButton
isDisabled={selectedRoles.length === 1}
ariaLabel="delete key"
className="h-9"
variant="outline_bg"
onClick={() => {
if (selectedRoles && selectedRoles?.length > 1) {
setValue(
"provider.roles",
selectedRoles.filter((__, idx) => idx !== i)
);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</FormControl>
)}
/>
))}
</div>
</div>
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
className="mb-3"
variant="outline_bg"
onClick={() => {
setValue("provider.roles", [...selectedRoles, ""]);
}}
>
Add Role
</Button>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA (SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,387 @@
import { Controller, useForm } from "react-hook-form";
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Select, SelectItem, TextArea } from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
enum CredentialType {
Dynamic = "dynamic",
Static = "static"
}
const credentialTypes = [
{
label: "Dynamic",
value: CredentialType.Dynamic
},
{
label: "Static",
value: CredentialType.Static
}
] as const;
const formSchema = z.object({
provider: z.discriminatedUnion("credentialType", [
z.object({
url: z.string().trim().min(1),
binddn: z.string().trim().min(1),
bindpass: z.string().trim().min(1),
ca: z.string().optional(),
credentialType: z.literal(CredentialType.Dynamic),
creationLdif: z.string().min(1),
revocationLdif: z.string().min(1),
rollbackLdif: z.string().optional()
}),
z.object({
url: z.string().trim().min(1),
binddn: z.string().trim().min(1),
bindpass: z.string().trim().min(1),
ca: z.string().optional(),
credentialType: z.literal(CredentialType.Static),
rotationLdif: z.string().min(1)
})
]),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const LdapInputForm = ({
onCompleted,
onCancel,
secretPath,
projectSlug,
environment
}: Props) => {
const {
control,
formState: { isSubmitting },
setValue,
watch,
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
url: "",
binddn: "",
bindpass: "",
ca: "",
creationLdif: "",
revocationLdif: "",
rollbackLdif: "",
credentialType: CredentialType.Dynamic
}
}
});
const selectedCredentialType = watch("provider.credentialType");
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Ldap, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
<a
href="https://infisical.com/docs/documentation/platform/dynamic-secrets/ldap"
target="_blank"
rel="noopener noreferrer"
>
<div className="mb-1 ml-2 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-xxs"
/>
</div>
</a>
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
name="provider.url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="URL"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.binddn"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Bind DN"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.bindpass"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Bind Password"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.credentialType"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Credential Type"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(e) => {
const ldifFields = [
"provider.creationLdif",
"provider.revocationLdif",
"provider.rollbackLdif",
"provider.rotationLdif"
] as const;
ldifFields.forEach((f) => {
setValue(f, "");
});
field.onChange(e);
}}
>
{credentialTypes.map((credentialType) => (
<SelectItem
value={credentialType.value}
key={`credential-type-${credentialType.value}`}
>
{credentialType.label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{selectedCredentialType === CredentialType.Dynamic && (
<>
<Controller
control={control}
name="provider.creationLdif"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation LDIF"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationLdif"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation LDIF"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.rollbackLdif"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Rollback LDIF"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea {...field} />
</FormControl>
)}
/>
</>
)}
{selectedCredentialType === CredentialType.Static && (
<Controller
control={control}
name="provider.rotationLdif"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Rotation LDIF"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea {...field} />
</FormControl>
)}
/>
)}
</div>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1,455 @@
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
FormLabel,
IconButton,
Input,
Select,
SelectItem
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
adminPublicKey: z.string().trim().min(1),
adminPrivateKey: z.string().trim().min(1),
groupId: z.string().trim().min(1),
roles: z
.object({
collectionName: z.string().optional(),
databaseName: z.string().min(1),
roleName: z.string().min(1)
})
.array()
.min(1),
scopes: z
.object({
name: z.string().min(1),
type: z.string().min(1)
})
.array()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
const ATLAS_SCOPE_TYPES = [
{
label: "Cluster",
value: "CLUSTER"
},
{
label: "Data Lake",
value: "DATA_LAKE"
},
{
label: "Stream",
value: "STREAM"
}
];
export const MongoAtlasInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
getValues,
setValue,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
roles: [{ databaseName: "", roleName: "" }]
}
}
});
const roleFields = useFieldArray({
control,
name: "provider.roles"
});
const scopeFields = useFieldArray({
control,
name: "provider.scopes"
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.MongoAtlas, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.adminPublicKey"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Admin Public Key"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.adminPrivateKey"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Admin Private Key"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="provider.groupId"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Group/Project ID"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Unique 24-hexadecimal digit string that identifies your project"
>
<Input {...field} />
</FormControl>
)}
/>
<FormLabel label="Roles" />
<div className="mb-3 flex flex-col space-y-2">
{roleFields.fields.map(({ id: roleFieldId }, i) => (
<div key={roleFieldId} className="flex items-end space-x-2">
<div className="flex-grow">
{i === 0 && <span className="text-xs text-mineshaft-400">Database Name</span>}
<Controller
control={control}
name={`provider.roles.${i}.databaseName`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
{i === 0 && (
<FormLabel
label="Collection Name"
className="text-xs text-mineshaft-400"
isOptional
/>
)}
<Controller
control={control}
name={`provider.roles.${i}.collectionName`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
{i === 0 && (
<FormLabel
label="Role"
className="text-xs text-mineshaft-400"
tooltipClassName="max-w-md whitespace-pre-line"
tooltipText={`Human-readable label that identifies a group of privileges assigned to a database user. This value can either be a built-in role or a custom role.
Built-in: atlasAdmin, backup, clusterMonitor, dbAdmin, dbAdminAnyDatabase, enableSharding, read, readAnyDatabase, readWrite, readWriteAnyDatabase.`}
/>
)}
<Controller
control={control}
name={`provider.roles.${i}.roleName`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 h-9"
variant="outline_bg"
onClick={() => {
const roles = getValues("provider.roles");
if (roles && roles?.length > 1) {
roleFields.remove(i);
} else {
setValue("provider.roles", [{ databaseName: "", roleName: "" }]);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => roleFields.append({ databaseName: "", roleName: "" })}
>
Add Role
</Button>
</div>
</div>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-section">
<AccordionTrigger>Advanced</AccordionTrigger>
<AccordionContent>
<FormLabel
label="Scopes"
isOptional
className="mb-2"
tooltipClassName="max-w-md whitespace-pre-line"
tooltipText="List that contains clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances that this database user can access. If omitted, MongoDB Cloud grants the database user access to all the clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances in the project."
/>
<div className="mb-2 flex flex-col space-y-2">
{scopeFields.fields.map(({ id: scopeFieldId }, i) => (
<div key={scopeFieldId} className="flex items-end space-x-2">
<div className="flex-grow">
{i === 0 && (
<FormLabel
label="Label"
className="text-xs text-mineshaft-400"
tooltipClassName="max-w-md whitespace-pre-line"
tooltipText="Human-readable label that identifies the cluster or MongoDB Atlas Data Lake that this database user can access."
/>
)}
<Controller
control={control}
name={`provider.scopes.${i}.name`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} placeholder="Cluster or data lake id" />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
{i === 0 && <span className="text-xs text-mineshaft-400">Type</span>}
<Controller
control={control}
name={`provider.scopes.${i}.type`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
{ATLAS_SCOPE_TYPES.map((el) => (
<SelectItem value={el.value} key={`atlas-scope-${el.value}`}>
{el.label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 h-9"
variant="outline_bg"
onClick={() => {
scopeFields.remove(i);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() =>
scopeFields.append({ name: "", type: ATLAS_SCOPE_TYPES[0].value })
}
>
Add Scope
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,338 @@
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, FormLabel, IconButton, Input, SecretInput } from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
host: z.string().toLowerCase().min(1),
port: z.coerce.number().optional(),
database: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
ca: z.string().optional(),
roles: z
.object({
roleName: z.string().min(1)
})
.array()
.min(1)
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const MongoDBDatabaseInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
setValue,
getValues,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
roles: [{ roleName: "readWrite" }]
}
}
});
const roleFields = useFieldArray({
control,
name: "provider.roles"
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: {
type: DynamicSecretProviders.MongoDB,
inputs: {
...provider,
port: provider?.port ? provider.port : undefined,
roles: provider.roles.map((el) => el.roleName)
}
},
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={27017}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.database"
defaultValue="default"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Database Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<FormLabel
label="Roles"
tooltipClassName="max-w-md whitespace-pre-line"
tooltipText={`Human-readable label that identifies a group of privileges assigned to a database user. This value can either be a built-in role or a custom role.
Built-in: atlasAdmin, backup, clusterMonitor, dbAdmin, dbAdminAnyDatabase, enableSharding, read, readAnyDatabase, readWrite, readWriteAnyDatabase.`}
/>
<div className="mb-3 mt-1 flex flex-col space-y-2">
{roleFields.fields.map(({ id: roleFieldId }, i) => (
<div key={roleFieldId} className="flex items-end space-x-2">
<div className="flex-grow">
<Controller
control={control}
name={`provider.roles.${i}.roleName`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 h-9"
variant="outline_bg"
onClick={() => {
const roles = getValues("provider.roles");
if (roles && roles?.length > 1) {
roleFields.remove(i);
} else {
setValue("provider.roles", [{ roleName: "" }]);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => roleFields.append({ roleName: "" })}
>
Add Role
</Button>
</div>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,422 @@
import { Controller, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, FormLabel, IconButton, Input, SecretInput } from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
host: z.string().trim().min(1),
port: z.coerce.number(), // important: this is the management plugin port
username: z.string(),
password: z.string(),
tags: z.array(z.string().trim()),
virtualHost: z.object({
name: z.string().trim().min(1),
permissions: z.object({
read: z.string().trim().min(1),
write: z.string().trim().min(1),
configure: z.string().trim().min(1)
})
}),
ca: z.string().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const RabbitMqInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit,
setValue,
watch
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
port: 15672,
virtualHost: {
name: "/",
permissions: {
read: ".*",
write: ".*",
configure: ".*"
}
},
tags: []
}
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.RabbitMq, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
const selectedTags = watch("provider.tags");
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input placeholder="https://your-rabbitmq-host.com" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={443}
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="The port on which the RabbitMQ management plugin is running. Default is 15672."
label="Management Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<div className="flex w-full items-center gap-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Username"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div>
<FormLabel label="Virtual Host" className="mb-2" />
<div className="mt-2 flex items-center justify-evenly gap-2">
<Controller
control={control}
name="provider.virtualHost.name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
tooltipText="The virtual host to which the user will be assigned. Default is /."
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input placeholder="/virtual-host" {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.virtualHost.permissions.read"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Read Permissions"
tooltipText="A regular expression matching resource names for which the user is granted read permissions."
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input placeholder=".*" {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.virtualHost.permissions.write"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Write Permissions"
tooltipText="A regular expression matching resource names for which the user is granted write permissions."
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input placeholder=".*" {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.virtualHost.permissions.configure"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Configure Permissions"
tooltipText="A regular expression matching resource names for which the user is granted configure permissions."
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input placeholder=".*" {...field} autoComplete="off" />
</FormControl>
)}
/>
</div>
</div>
<div className="flex flex-col">
<FormLabel
isOptional
className="mb-2"
label="Tags"
tooltipText={
<div className="space-y-4">
<p>Select which tag(s) to assign the users provisioned by Infisical.</p>
<p>
There is a wide range of in-built roles in RabbitMQ. Some include,
management, policymaker, monitoring, administrator. <br />
<a
href="https://www.rabbitmq.com/docs/management#permissions"
target="_blank"
rel="noopener noreferrer"
>
<span className="cursor-pointer text-primary-400">
Read more about management tags here
</span>
</a>
.
</p>
<p>
You can also assign custom roles by providing the name of the custom role in
the input field.
</p>
</div>
}
/>
<div className="flex flex-col -space-y-2">
{selectedTags.map((_, i) => (
<Controller
control={control}
name={`provider.tags.${i}`}
// eslint-disable-next-line react/no-array-index-key
key={`role-${i}`}
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<div className="flex h-9 items-center gap-2">
<Input
placeholder="Insert tag name, (management, policymaker, etc.)"
className="mb-0 flex-grow"
{...field}
/>
<IconButton
isDisabled={selectedTags.length === 1}
ariaLabel="delete key"
className="h-9"
variant="outline_bg"
onClick={() => {
if (selectedTags && selectedTags?.length > 1) {
setValue(
"provider.tags",
selectedTags.filter((__, idx) => idx !== i)
);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</FormControl>
)}
/>
))}
</div>
</div>
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
className="mb-4"
variant="outline_bg"
onClick={() => {
setValue("provider.tags", [...selectedTags, ""]);
}}
>
Add Tag
</Button>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA (SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,331 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
SecretInput,
TextArea
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
host: z.string().toLowerCase().min(1),
port: z.coerce.number(),
username: z.string().min(1),
password: z.string().min(1).optional(),
creationStatement: z.string().min(1),
renewStatement: z.string().optional(),
revocationStatement: z.string().min(1),
ca: z.string().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const RedisInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
username: "default",
port: 6379,
creationStatement: "ACL SETUSER {{username}} on >{{password}} ~* &* +@all",
revocationStatement: "ACL DELUSER {{username}}"
}
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Redis, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={5432}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<div className="flex w-full items-center space-x-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.password"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Required if your Redis instance is password protected."
label="Password"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify Redis Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.renewStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,309 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
TextArea
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
host: z.string().toLowerCase().min(1),
port: z.coerce.number(),
database: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
creationStatement: z.string().min(1),
revocationStatement: z.string().min(1)
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const SapAseInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
database: "master",
port: 5000,
creationStatement: `sp_addlogin '{{username}}', '{{password}}';
sp_adduser '{{username}}', '{{username}}', null;
sp_role 'grant', 'mon_role', '{{username}}';`,
revocationStatement: `sp_dropuser '{{username}}';
sp_droplogin '{{username}}';`
}
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.SapAse, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input placeholder="92.41.22.72" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={5000}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.database"
defaultValue="master"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Database"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="text" />
</FormControl>
)}
/>
</div>
<div className="flex w-full items-center space-x-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
className="w-full"
label="User"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.password"
render={({ field, fieldState: { error } }) => (
<FormControl
className="w-full"
label="Password"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify SQL Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username and password are dynamically provisioned. The sp_addlogin statement is automatically called against the master database. All other statements are called against the database you specify."
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned. The sp_droplogin statement is automatically called against the master database. All other statements are called against the database you specify."
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,329 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
SecretInput,
TextArea
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
host: z.string().toLowerCase().min(1),
port: z.coerce.number(),
username: z.string().min(1),
password: z.string().min(1),
creationStatement: z.string().min(1),
revocationStatement: z.string().min(1),
renewStatement: z.string().optional(),
ca: z.string().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const SapHanaInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
creationStatement: `CREATE USER {{username}} PASSWORD {{password}} NO FORCE_FIRST_PASSWORD_CHANGE VALID UNTIL '{{expiration}}';
GRANT "MONITORING" TO {{username}};`,
revocationStatement: `REVOKE "MONITORING" FROM {{username}};
DROP USER {{username}};`,
renewStatement: "ALTER USER {{username}} VALID UNTIL '{{expiration}}';"
}
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.SapHana, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={443}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify SQL Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.renewStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,331 @@
import { Controller, useForm } from "react-hook-form";
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
TextArea
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
accountId: z.string().trim().min(1),
orgId: z.string().trim().min(1),
username: z.string().trim().min(1),
password: z.string().trim().min(1),
creationStatement: z.string().trim().min(1),
revocationStatement: z.string().trim().min(1),
renewStatement: z.string().trim().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z
.string()
.trim()
.min(1)
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const SnowflakeInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
creationStatement:
"CREATE USER {{username}} PASSWORD = '{{password}}' DEFAULT_ROLE = public DEFAULT_SECONDARY_ROLES = ('ALL') MUST_CHANGE_PASSWORD = FALSE DAYS_TO_EXPIRY = {{expiration}};",
revocationStatement: "DROP USER {{username}};",
renewStatement: "ALTER USER {{username}} SET DAYS_TO_EXPIRY = {{expiration}};"
}
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Snowflake, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch (err) {
createNotification({
type: "error",
text: err instanceof Error ? err.message : "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
<a
href="https://infisical.com/docs/documentation/platform/dynamic-secrets/snowflake"
target="_blank"
rel="noopener noreferrer"
>
<div className="mb-1 ml-2 inline-block rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-xxs"
/>
</div>
</a>
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.accountId"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Account Identifier"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.orgId"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Organization Identifier"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.password"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify SQL Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.renewStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,424 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
SecretInput,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
client: z.nativeEnum(SqlProviders),
host: z.string().toLowerCase().min(1),
port: z.coerce.number(),
database: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
creationStatement: z.string().min(1),
revocationStatement: z.string().min(1),
renewStatement: z.string().optional(),
ca: z.string().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
const getSqlStatements = (provider: SqlProviders) => {
if (provider === SqlProviders.MySql) {
return {
creationStatement:
"CREATE USER \"{{username}}\"@'%' IDENTIFIED BY '{{password}}';\nGRANT ALL ON \"{{database}}\".* TO \"{{username}}\"@'%';",
renewStatement: "",
revocationStatement:
'REVOKE ALL PRIVILEGES ON "{{database}}".* FROM "{{username}}"@\'%\';\nDROP USER "{{username}}"@\'%\';'
};
}
if (provider === SqlProviders.Oracle) {
return {
creationStatement:
'CREATE USER "{{username}}" IDENTIFIED BY "{{password}}";\nGRANT CONNECT TO "{{username}}";\nGRANT CREATE SESSION TO "{{username}}";',
renewStatement: "",
revocationStatement:
'REVOKE CONNECT FROM "{{username}}";\nREVOKE CREATE SESSION FROM "{{username}}";\nDROP USER "{{username}}";'
};
}
if (provider === SqlProviders.MsSQL) {
return {
creationStatement:
"CREATE LOGIN [{{username}}] WITH PASSWORD = '{{password}}';\nCREATE USER [{{username}}] FOR LOGIN [{{username}}];\nGRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [{{username}}];",
renewStatement: "",
revocationStatement: "DROP USER [{{username}}];\nDROP LOGIN [{{username}}];"
};
}
return {
creationStatement:
"CREATE USER \"{{username}}\" WITH ENCRYPTED PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';\nGRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"{{username}}\";",
renewStatement: "ALTER ROLE \"{{username}}\" VALID UNTIL '{{expiration}}';",
revocationStatement:
'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "{{username}}";\nDROP ROLE "{{username}}";'
};
};
const getDefaultPort = (provider: SqlProviders) => {
switch (provider) {
case SqlProviders.MySql:
return 3306;
case SqlProviders.Oracle:
return 1521;
case SqlProviders.MsSQL:
return 1433;
default:
return 5432;
}
};
export const SqlDatabaseInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
setValue,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: getSqlStatements(SqlProviders.Postgres)
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.SqlDatabase, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
const handleDatabaseChange = (type: SqlProviders) => {
const sqlStatment = getSqlStatements(type);
setValue("provider.creationStatement", sqlStatment.creationStatement);
setValue("provider.renewStatement", sqlStatment.renewStatement);
setValue("provider.revocationStatement", sqlStatment.revocationStatement);
setValue("provider.port", getDefaultPort(type));
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="pb-0.5 pl-1 text-sm text-mineshaft-400">Service</div>
<Controller
control={control}
name="provider.client"
defaultValue={SqlProviders.Postgres}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Select
value={value}
onValueChange={(val) => {
onChange(val);
handleDatabaseChange(val as SqlProviders);
}}
className="w-full border border-mineshaft-500"
>
<SelectItem value={SqlProviders.Postgres}>PostgreSQL</SelectItem>
<SelectItem value={SqlProviders.MySql}>MySQL</SelectItem>
<SelectItem value={SqlProviders.Oracle}>Oracle</SelectItem>
<SelectItem value={SqlProviders.MsSQL}>MS SQL</SelectItem>
</Select>
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={5432}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.database"
defaultValue="default"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Database Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify SQL Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.renewStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,312 @@
import { Controller, useForm } from "react-hook-form";
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
enum ConfigType {
URL = "url",
MANUAL = "manual"
}
enum TotpAlgorithm {
SHA1 = "sha1",
SHA256 = "sha256",
SHA512 = "sha512"
}
const formSchema = z.object({
provider: z.discriminatedUnion("configType", [
z.object({
configType: z.literal(ConfigType.URL),
url: z
.string()
.url()
.trim()
.min(1)
.refine((val) => {
const urlObj = new URL(val);
const secret = urlObj.searchParams.get("secret");
return Boolean(secret);
}, "OTP URL must contain secret field")
}),
z.object({
configType: z.literal(ConfigType.MANUAL),
secret: z
.string()
.trim()
.min(1)
.transform((val) => val.replace(/\s+/g, "")),
period: z.number().optional(),
algorithm: z.nativeEnum(TotpAlgorithm).optional(),
digits: z.number().optional()
})
]),
name: z
.string()
.trim()
.min(1)
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
export const TotpInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
watch,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: {
configType: ConfigType.URL
}
}
});
const selectedConfigType = watch("provider.configType");
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, provider }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Totp, inputs: provider },
maxTTL: "24h",
name,
path: secretPath,
defaultTTL: "1m",
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch (err) {
createNotification({
type: "error",
text: err instanceof Error ? err.message : "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
<a
href="https://infisical.com/docs/documentation/platform/dynamic-secrets/totp"
target="_blank"
rel="noopener noreferrer"
>
<div className="mb-1 ml-2 inline-block rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-xxs"
/>
</div>
</a>
</div>
<div className="flex flex-col">
<Controller
control={control}
name="provider.configType"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Configuration Type"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(val) => {
field.onChange(val);
}}
dropdownContainerClassName="max-w-full"
>
<SelectItem value={ConfigType.URL} key="config-type-url">
URL
</SelectItem>
<SelectItem value={ConfigType.MANUAL} key="config-type-manual">
Manual
</SelectItem>
</Select>
</FormControl>
)}
/>
{selectedConfigType === ConfigType.URL && (
<Controller
control={control}
name="provider.url"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="OTP URL"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="otpauth://" />
</FormControl>
)}
/>
)}
{selectedConfigType === ConfigType.MANUAL && (
<>
<Controller
control={control}
name="provider.secret"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Key"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex flex-row gap-2">
<Controller
control={control}
name="provider.period"
defaultValue={30}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Period"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
type="number"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.digits"
defaultValue={6}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Digits"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
type="number"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.algorithm"
defaultValue={TotpAlgorithm.SHA1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Algorithm"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
dropdownContainerClassName="max-w-full"
onValueChange={(val) => {
field.onChange(val);
}}
>
<SelectItem value={TotpAlgorithm.SHA1} key="algorithm-sha-1">
SHA1
</SelectItem>
<SelectItem value={TotpAlgorithm.SHA256} key="algorithm-sha-256">
SHA256
</SelectItem>
<SelectItem value={TotpAlgorithm.SHA512} key="algorithm-sha-512">
SHA512
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<p className="mb-8 text-sm font-normal text-gray-400">
The period, digits, and algorithm values can remain at their defaults unless
your TOTP provider specifies otherwise.
</p>
</>
)}
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1 @@
export { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
@@ -0,0 +1,195 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosError } from "axios";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useSubscription, useWorkspace } from "@app/context";
import { useCreateSecretImport } from "@app/hooks/api";
const typeSchema = z.object({
environment: z.object({ name: z.string(), slug: z.string() }),
secretPath: z
.string()
.trim()
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
),
isReplication: z.boolean().default(false)
});
type TFormSchema = z.infer<typeof typeSchema>;
type Props = {
environment: string;
workspaceId: string;
secretPath?: string;
// modal props
isOpen?: boolean;
onClose: () => void;
onTogglePopUp: (isOpen: boolean) => void;
onUpgradePlan: () => void;
};
export const CreateSecretImportForm = ({
environment,
workspaceId,
secretPath = "/",
isOpen,
onClose,
onTogglePopUp,
onUpgradePlan
}: Props) => {
const {
handleSubmit,
control,
reset,
watch,
formState: { isSubmitting }
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const selectedEnvironment = watch("environment");
const { subscription } = useSubscription();
const { mutateAsync: createSecretImport } = useCreateSecretImport();
const handleFormSubmit = async ({
environment: importedEnv,
secretPath: importedSecPath,
isReplication
}: TFormSchema) => {
try {
if (isReplication && !subscription?.secretApproval) {
onUpgradePlan();
return;
}
await createSecretImport({
environment,
projectId: workspaceId,
path: secretPath,
isReplication,
import: {
environment: importedEnv.slug,
path: importedSecPath
}
});
onClose();
reset();
createNotification({
type: "success",
text: `Successfully linked. ${
isReplication ? "Please refresh the dashboard to view changes" : ""
}`
});
} catch (err) {
console.error(err);
const axiosError = err as AxiosError;
if (axiosError?.response?.status === 401) {
createNotification({
text: "You do not have access to the selected environment/path",
type: "error"
});
} else {
createNotification({
type: "error",
text: "Failed to link secrets"
});
}
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
<ModalContent
bodyClassName="overflow-visible"
title="Add Secret Link"
subTitle="To inherit secrets from another environment or folder"
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller
control={control}
name="environment"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
<FilterableSelect
options={environments}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
placeholder="Select environment..."
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
<SecretPathInput {...field} environment={selectedEnvironment?.slug} />
</FormControl>
)}
/>
<Controller
name="isReplication"
control={control}
defaultValue={false}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
value
? "Secrets from the source will be automatically sent to the destination. If approval policies exist at the destination, the secrets will be sent as approval requests instead of being applied immediately."
: "Secrets from the source location will be imported to the selected destination immediately, ignoring any approval policies at the destination."
}
>
<Select
value={value ? "true" : "false"}
onValueChange={(val) => onChange(val === "true")}
className="w-full border border-mineshaft-500"
>
<SelectItem value="false">Ignore secret approval polices</SelectItem>
<SelectItem value="true">Respect secret approval polices</SelectItem>
</Select>
</FormControl>
)}
/>
<div className="mt-7 flex items-center">
<Button
isDisabled={isSubmitting}
isLoading={isSubmitting}
key="layout-create-project-submit"
className="mr-4"
type="submit"
>
Create Link
</Button>
<Button
key="layout-cancel-create-project"
onClick={onClose}
variant="plain"
colorSchema="secondary"
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,76 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, Input, ModalClose } from "@app/components/v2";
type Props = {
onCreateFolder?: (folderName: string) => Promise<void>;
onUpdateFolder?: (folderName: string) => Promise<void>;
isEdit?: boolean;
defaultFolderName?: string;
};
const formSchema = z.object({
name: z
.string()
.trim()
.regex(
/^[a-zA-Z0-9-_]+$/,
"Folder name can only contain letters, numbers, dashes, and underscores"
)
});
type TFormData = z.infer<typeof formSchema>;
export const FolderForm = ({
isEdit,
defaultFolderName,
onCreateFolder,
onUpdateFolder
}: Props): JSX.Element => {
const {
control,
reset,
formState: { isSubmitting },
handleSubmit
} = useForm<TFormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: defaultFolderName
}
});
const onSubmit = async ({ name }: TFormData) => {
if (isEdit) {
await onUpdateFolder?.(name);
} else {
await onCreateFolder?.(name);
}
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl label="Folder Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="Type your folder name" />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
{isEdit ? "Save" : "Create"}
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
@@ -0,0 +1,147 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
Checkbox,
FormControl,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useWorkspace } from "@app/context";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["moveSecrets"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["moveSecrets"]>, state?: boolean) => void;
onMoveApproved: (moveParams: {
destinationEnvironment: string;
destinationSecretPath: string;
shouldOverwrite: boolean;
}) => void;
};
const formSchema = z.object({
environment: z.string().trim(),
secretPath: z
.string()
.trim()
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
),
shouldOverwrite: z.boolean().default(false)
});
type TFormSchema = z.infer<typeof formSchema>;
export const MoveSecretsModal = ({ popUp, handlePopUpToggle, onMoveApproved }: Props) => {
const {
handleSubmit,
control,
reset,
watch,
formState: { isSubmitting }
} = useForm<TFormSchema>({ resolver: zodResolver(formSchema) });
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const selectedEnvironment = watch("environment");
const handleFormSubmit = (data: TFormSchema) => {
onMoveApproved({
destinationEnvironment: data.environment,
destinationSecretPath: data.secretPath,
shouldOverwrite: data.shouldOverwrite
});
handlePopUpToggle("moveSecrets", false);
};
return (
<Modal
isOpen={popUp.moveSecrets.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("moveSecrets", isOpen);
}}
>
<ModalContent
title="Move Secrets"
subTitle="Move secrets from the current path to the selected destination"
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller
control={control}
name="environment"
defaultValue={environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
<SecretPathInput {...field} environment={selectedEnvironment} />
</FormControl>
)}
/>
<Controller
control={control}
name="shouldOverwrite"
defaultValue={false}
render={({ field: { onBlur, value, onChange } }) => (
<Checkbox
id="overwrite-checkbox"
className="ml-2"
isChecked={value}
onCheckedChange={onChange}
onBlur={onBlur}
>
Overwrite existing secrets
</Checkbox>
)}
/>
<div className="mt-7 flex items-center">
<Button
isDisabled={isSubmitting}
isLoading={isSubmitting}
key="move-secrets-submit"
className="mr-4"
type="submit"
>
Move
</Button>
<Button
key="move-secrets-cancel"
onClick={() => handlePopUpToggle("moveSecrets", false)}
variant="plain"
colorSchema="secondary"
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { ActionBar } from "./ActionBar";
@@ -0,0 +1,230 @@
import { ClipboardEvent, useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { getKeyValue } from "@app/helpers/parseEnvVar";
import { useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
import { SecretType } from "@app/hooks/api/types";
import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store";
const typeSchema = z.object({
key: z.string().trim().min(1, { message: "Secret key is required" }),
value: z.string().optional(),
tags: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).optional()
});
type TFormSchema = z.infer<typeof typeSchema>;
type Props = {
environment: string;
workspaceId: string;
secretPath?: string;
// modal props
autoCapitalize?: boolean;
isProtectedBranch?: boolean;
};
export const CreateSecretForm = ({
environment,
workspaceId,
secretPath = "/",
autoCapitalize = true,
isProtectedBranch = false
}: Props) => {
const {
register,
handleSubmit,
control,
reset,
setValue,
watch,
formState: { errors, isSubmitting }
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
const { closePopUp } = usePopUpAction();
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const createWsTag = useCreateWsTag();
const { permission } = useProjectPermission();
const canReadTags = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
const { data: projectTags, isPending: isTagsLoading } = useGetWsTags(
canReadTags ? workspaceId : ""
);
const secretKeyInputRef = useRef<HTMLInputElement>(null);
const { ref: setSecretKeyHookRef, ...secretKeyRegisterRest } = register("key");
const secretKey = watch("key");
const slugSchema = z.string().trim().toLowerCase().min(1);
const createNewTag = async (slug: string) => {
// TODO: Replace with slugSchema generic
try {
const parsedSlug = slugSchema.parse(slug);
await createWsTag.mutateAsync({
workspaceID: workspaceId,
tagSlug: parsedSlug,
tagColor: ""
});
} catch {
createNotification({
type: "error",
text: "Failed to create new tag"
});
}
};
const handleFormSubmit = async ({ key, value, tags }: TFormSchema) => {
try {
await createSecretV3({
environment,
workspaceId,
secretPath,
secretKey: key,
secretValue: value || "",
secretComment: "",
type: SecretType.Shared,
tagIds: tags?.map((el) => el.value)
});
closePopUp(PopUpNames.CreateSecretForm);
reset();
createNotification({
type: isProtectedBranch ? "info" : "success",
text: isProtectedBranch
? "Requested changes have been sent for review"
: "Successfully created secret"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to create secret"
});
}
};
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
const delimitters = [":", "="];
const pastedContent = e.clipboardData.getData("text");
const { key, value } = getKeyValue(pastedContent, delimitters);
const isWholeKeyHighlighted =
secretKeyInputRef.current &&
secretKeyInputRef.current.selectionStart === 0 &&
secretKeyInputRef.current.selectionEnd === secretKeyInputRef.current.value.length;
if (!secretKey || isWholeKeyHighlighted) {
e.preventDefault();
setValue("key", key);
if (value) {
setValue("value", value);
}
}
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} noValidate>
<FormControl
label="Key"
isRequired
isError={Boolean(errors?.key)}
errorText={errors?.key?.message}
>
<Input
{...secretKeyRegisterRest}
ref={(e) => {
setSecretKeyHookRef(e);
// @ts-expect-error this is for multiple ref single component
secretKeyInputRef.current = e;
}}
placeholder="Type your secret name"
onPaste={handlePaste}
autoCapitalization={autoCapitalize}
/>
</FormControl>
<Controller
control={control}
name="value"
render={({ field }) => (
<FormControl
label="Value"
isError={Boolean(errors?.value)}
errorText={errors?.value?.message}
>
<InfisicalSecretInput
{...field}
environment={environment}
secretPath={secretPath}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="tags"
render={({ field }) => (
<FormControl
label="Tags"
isError={Boolean(errors?.value)}
errorText={errors?.value?.message}
helperText={
!canReadTags ? (
<div className="flex items-center space-x-2">
<FontAwesomeIcon icon={faTriangleExclamation} className="text-yellow-400" />
<span>You do not have permission to read tags.</span>
</div>
) : (
""
)
}
>
<CreatableSelect
isMulti
className="w-full"
placeholder="Select tags to assign to secret..."
isValidNewOption={(v) => slugSchema.safeParse(v).success}
name="tagIds"
isDisabled={!canReadTags}
isLoading={isTagsLoading && canReadTags}
options={projectTags?.map((el) => ({ label: el.slug, value: el.id }))}
value={field.value}
onChange={field.onChange}
onCreateOption={createNewTag}
/>
</FormControl>
)}
/>
<div className="mt-7 flex items-center">
<Button
isDisabled={isSubmitting}
isLoading={isSubmitting}
key="layout-create-project-submit"
className="mr-4"
type="submit"
>
Create Secret
</Button>
<Button
key="layout-cancel-create-project"
onClick={() => closePopUp(PopUpNames.CreateSecretForm)}
variant="plain"
colorSchema="secondary"
>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1 @@
export { CreateSecretForm } from "./CreateSecretForm";

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