Merge remote-tracking branch 'origin/main' into feat/native-slack-integration

This commit is contained in:
Sheen Capadngan
2024-09-04 23:10:15 +08:00
93 changed files with 6556 additions and 1663 deletions
@@ -9,36 +9,42 @@ import { Tooltip } from "../Tooltip";
export type FormLabelProps = {
id?: string;
isRequired?: boolean;
isOptional?:boolean;
isOptional?: boolean;
label?: ReactNode;
icon?: ReactNode;
className?: string;
tooltipText?: string;
tooltipClassName?: string;
tooltipText?: ReactNode;
};
export const FormLabel = ({ id, label, isRequired, icon, className,isOptional, tooltipText }: FormLabelProps) => (
export const FormLabel = ({
id,
label,
isRequired,
icon,
className,
isOptional,
tooltipClassName,
tooltipText
}: FormLabelProps) => (
<Label.Root
className={twMerge(
"mb-0.5 ml-1 flex items-center text-sm font-normal text-mineshaft-400",
"mb-0.5 flex items-center text-sm font-normal text-mineshaft-400",
className
)}
htmlFor={id}
>
{label}
{isRequired && <span className="ml-1 text-red">*</span>}
{isOptional && <span className="ml-1 text-gray-500 italic text-xs">- Optional</span>}
{isOptional && <span className="ml-1 text-xs italic text-gray-500">- Optional</span>}
{icon && !tooltipText && (
<span className="ml-2 cursor-default text-mineshaft-300 hover:text-mineshaft-200">
{icon}
</span>
)}
{tooltipText && (
<Tooltip content={tooltipText}>
<FontAwesomeIcon
icon={faQuestionCircle}
size="1x"
className="ml-2"
/>
<Tooltip content={tooltipText} className={tooltipClassName}>
<FontAwesomeIcon icon={faQuestionCircle} size="1x" className="ml-2" />
</Tooltip>
)}
</Label.Root>
+41 -1
View File
@@ -20,7 +20,9 @@ export enum DynamicSecretProviders {
Cassandra = "cassandra",
AwsIam = "aws-iam",
Redis = "redis",
AwsElastiCache = "aws-elasticache"
AwsElastiCache = "aws-elasticache",
MongoAtlas = "mongo-db-atlas",
ElasticSearch = "elastic-search"
}
export enum SqlProviders {
@@ -97,6 +99,44 @@ export type TDynamicSecretProvider =
revocationStatement: string;
ca?: string | undefined;
};
}
| {
type: DynamicSecretProviders.MongoAtlas;
inputs: {
adminPublicKey: string;
adminPrivateKey: string;
groupId: string;
roles: {
databaseName: string;
roleName: string;
collectionName?: string;
}[];
scopes?: {
name: string;
type: string;
}[];
};
}
| {
type: DynamicSecretProviders.ElasticSearch;
inputs: {
host: string;
port: number;
ca?: string | undefined;
roles: string[];
auth:
| {
type: "user";
username: string;
password: string;
}
| {
type: "api-key";
apiKey: string;
apiKeyId: string;
};
};
};
export type TCreateDynamicSecretDTO = {
@@ -71,6 +71,8 @@ export const useCreateIntegration = () => {
key: string;
value: string;
}[];
githubVisibility?: string;
githubVisibilityRepoIds?: string[];
kmsKeyId?: string;
shouldDisableDelete?: boolean;
shouldMaskSecrets?: boolean;
@@ -34,6 +34,9 @@ export type TIntegration = {
syncMessage?: string;
__v: number;
metadata?: {
githubVisibility?: string;
githubVisibilityRepoIds?: string[];
secretSuffix?: string;
syncBehavior?: IntegrationSyncBehavior;
mappingBehavior?: IntegrationMappingBehavior;
+20 -19
View File
@@ -7,7 +7,11 @@ import { TSharedSecret, TViewSharedSecretResponse } from "./types";
export const secretSharingKeys = {
allSharedSecrets: () => ["sharedSecrets"] as const,
specificSharedSecrets: ({ offset, limit }: { offset: number; limit: number }) =>
[...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const
[...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const,
getSecretById: (arg: { id: string; hashedHex: string; password?: string }) => [
"shared-secret",
arg
]
};
export const useGetSharedSecrets = ({
@@ -38,31 +42,28 @@ export const useGetSharedSecrets = ({
export const useGetActiveSharedSecretById = ({
sharedSecretId,
hashedHex
hashedHex,
password
}: {
sharedSecretId: string;
hashedHex: string;
password?: string;
}) => {
return useQuery<TViewSharedSecretResponse, [string]>({
enabled: Boolean(sharedSecretId) && Boolean(hashedHex),
queryFn: async () => {
const params = new URLSearchParams({
hashedHex
});
const { data } = await apiRequest.get<TViewSharedSecretResponse>(
return useQuery<TViewSharedSecretResponse>(
secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, password }),
async () => {
const { data } = await apiRequest.post<TViewSharedSecretResponse>(
`/api/v1/secret-sharing/public/${sharedSecretId}`,
{
params
hashedHex,
password
}
);
return {
encryptedValue: data.encryptedValue,
iv: data.iv,
tag: data.tag,
accessType: data.accessType,
orgName: data.orgName
};
return data;
},
{
enabled: Boolean(sharedSecretId) && Boolean(hashedHex)
}
});
);
};
+10 -5
View File
@@ -15,6 +15,7 @@ export type TSharedSecret = {
export type TCreateSharedSecretRequest = {
name?: string;
password?: string;
encryptedValue: string;
hashedHex: string;
iv: string;
@@ -25,11 +26,14 @@ export type TCreateSharedSecretRequest = {
};
export type TViewSharedSecretResponse = {
encryptedValue: string;
iv: string;
tag: string;
accessType: SecretSharingAccessType;
orgName?: string;
isPasswordProtected: boolean;
secret: {
encryptedValue: string;
iv: string;
tag: string;
accessType: SecretSharingAccessType;
orgName?: string;
};
};
export type TDeleteSharedSecretRequest = {
@@ -40,3 +44,4 @@ export enum SecretSharingAccessType {
Anyone = "anyone",
Organization = "organization"
}
+176 -27
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import Head from "next/head";
import Image from "next/image";
@@ -53,6 +53,21 @@ enum TabSections {
Options = "options"
}
const secretsVisibility = [
{
value: "selected",
label: "Select repositories"
},
{
value: "all",
label: "All public repositories"
},
{
value: "private",
label: "All private repositories"
}
] as const;
const targetEnv = ["github-repo", "github-org", "github-env"] as const;
type TargetEnv = (typeof targetEnv)[number];
@@ -63,9 +78,12 @@ const schema = yup.object({
shouldEnableDelete: yup.boolean().optional(),
scope: yup.mixed<TargetEnv>().oneOf(targetEnv.slice()).required(),
repoIds: yup.mixed().when("scope", {
is: "github-repo",
then: yup.array(yup.string().required()).min(1, "Select at least one repositories")
// Explanation: If scope is (github-repo) OR (github-org AND visibility is set to selected), then repoIds is required
repoIds: yup.mixed().when(["scope", "visibility"], {
is: (scope: string, visibility: string) =>
scope === "github-repo" || (scope === "github-org" && visibility === "selected"),
then: yup.array(yup.string().required()).min(1, "Select at least one repository"),
otherwise: yup.mixed().notRequired()
}),
repoId: yup.mixed().when("scope", {
@@ -91,6 +109,10 @@ const schema = yup.object({
orgId: yup.mixed().when("scope", {
is: "github-org",
then: yup.string().required("Organization is required")
}),
visibility: yup.mixed().when("scope", {
is: "github-org",
then: yup.string().required("Visibility is required")
})
});
@@ -121,6 +143,7 @@ export default function GitHubCreateIntegrationPage() {
secretPath: "/",
scope: "github-repo",
repoIds: [],
visibility: "all",
shouldEnableDelete: false
}
});
@@ -130,6 +153,7 @@ export default function GitHubCreateIntegrationPage() {
const repoIds = watch("repoIds");
const repoName = watch("repoName");
const repoOwner = watch("repoOwner");
const selectedOrgId = watch("orgId");
const { data: integrationAuthGithubEnvs } = useGetIntegrationAuthGithubEnvs(
integrationAuthId as string,
@@ -196,6 +220,8 @@ export default function GitHubCreateIntegrationPage() {
scope: data.scope,
owner: integrationAuthOrgs?.find((e) => e.orgId === data.orgId)?.name,
metadata: {
githubVisibility: data.visibility,
githubVisibilityRepoIds: data.repoIds,
secretSuffix: data.secretSuffix,
shouldEnableDelete: data.shouldEnableDelete
}
@@ -242,6 +268,15 @@ export default function GitHubCreateIntegrationPage() {
}
};
const selectedOrganization = useMemo(() => {
if (!integrationAuthApps) return null;
return integrationAuthApps.filter(
(authApp) =>
integrationAuthOrgs?.find((e) => e.orgId === selectedOrgId)?.name === authApp.owner
);
}, [selectedOrgId, integrationAuthApps]);
return integrationAuth && workspace && integrationAuthApps ? (
<div className="flex h-full w-full flex-col items-center justify-center py-4">
<Head>
@@ -339,7 +374,10 @@ export default function GitHubCreateIntegrationPage() {
<FormControl label="Scope" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
onValueChange={onChange}
onValueChange={(e) => {
setValue("repoIds", []);
onChange(e);
}}
className="w-full border border-mineshaft-500"
>
<SelectItem value="github-org">Organization</SelectItem>
@@ -430,32 +468,143 @@ export default function GitHubCreateIntegrationPage() {
/>
)}
{scope === "github-org" && (
<Controller
control={control}
name="orgId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Organization"
errorText={
integrationAuthOrgs?.length ? error?.message : "No organizations found"
}
isError={Boolean(integrationAuthOrgs?.length && error?.message)}
>
<Select
value={field.value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
<>
<Controller
control={control}
name="orgId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Organization"
errorText={
integrationAuthOrgs?.length ? error?.message : "No organizations found"
}
isError={Boolean(integrationAuthOrgs?.length && error?.message)}
>
{integrationAuthOrgs &&
integrationAuthOrgs.map(({ name, orgId }) => (
<SelectItem key={`github-organization-${orgId}`} value={orgId}>
{name}
<Select
value={field.value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
>
{integrationAuthOrgs &&
integrationAuthOrgs.map(({ name, orgId }) => (
<SelectItem key={`github-organization-${orgId}`} value={orgId}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="visibility"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
tooltipText="Select which type of repositories that the secrets should be synced to."
label="Visibility"
errorText={error?.message}
isError={Boolean(error?.message)}
>
<Select
value={field.value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
>
{secretsVisibility.map(({ label, value }) => (
<SelectItem key={`github-visibility-${value}`} value={value}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
</Select>
</FormControl>
)}
/>
{watch("visibility") === "selected" && (
<Controller
control={control}
name="repoIds"
render={({ field: { onChange }, fieldState: { error } }) => (
<FormControl
label="Selected Repositories"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{integrationAuthApps.length > 0 ? (
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
{repoIds.length === 1
? integrationAuthApps?.reduce(
(acc, { appId, name, owner }) =>
repoIds[0] === appId ? `${owner}/${name}` : acc,
""
)
: `${repoIds.length} repositories selected`}
<FontAwesomeIcon icon={faAngleDown} className="text-xs" />
</div>
) : (
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
No repositories found
</div>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="thin-scrollbar z-[100] max-h-80 overflow-y-scroll"
>
{selectedOrganization ? (
selectedOrganization.map((integrationAuthApp) => {
const isSelected = repoIds.includes(
String(integrationAuthApp.appId)
);
return (
<DropdownMenuItem
onClick={() => {
if (repoIds.includes(String(integrationAuthApp.appId))) {
onChange(
repoIds.filter(
(appId: string) =>
appId !== String(integrationAuthApp.appId)
)
);
} else {
onChange([
...repoIds,
String(integrationAuthApp.appId)
]);
}
}}
key={`repos-id-${integrationAuthApp.appId}`}
icon={
isSelected ? (
<FontAwesomeIcon
icon={faCheckCircle}
className="pr-0.5 text-primary"
/>
) : (
<div className="pl-[1.01rem]" />
)
}
iconPos="left"
className="w-[28.4rem] text-sm"
>
{integrationAuthApp.owner}/{integrationAuthApp.name}
</DropdownMenuItem>
);
})
) : (
<div />
)}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
)}
/>
</>
)}
{scope === "github-env" && (
<Controller
@@ -0,0 +1,229 @@
import {
faArrowRight,
faCalendarCheck,
faRefresh,
faWarning,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { integrationSlugNameMapping } from "public/data/frequentConstants";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormLabel, IconButton, Tag, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types";
import { TIntegration } from "@app/hooks/api/types";
type IProps = {
integration: TIntegration;
environments: Array<{ name: string; slug: string; id: string }>;
onRemoveIntegration: VoidFunction;
onManualSyncIntegration: VoidFunction;
};
export const ConfiguredIntegrationItem = ({
integration,
environments,
onRemoveIntegration,
onManualSyncIntegration
}: IProps) => {
return (
<div
className="max-w-8xl flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3"
key={`integration-${integration?.id.toString()}`}
>
<div className="flex">
<div className="ml-2 flex flex-col">
<FormLabel label="Environment" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{environments.find((e) => e.id === integration.envId)?.name || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Secret Path" />
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.secretPath}
</div>
</div>
<div className="flex h-full items-center">
<FontAwesomeIcon icon={faArrowRight} className="mx-4 text-gray-400" />
</div>
<div className="ml-4 flex flex-col">
<FormLabel
tooltipText={
integration.integration === "github" ? (
<div className="text-xs">
{/* 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
}
label="Integration"
/>
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integrationSlugNameMapping[integration.integration]}
</div>
</div>
{integration.integration === "qovery" && (
<div className="flex flex-row">
<div className="ml-2 flex flex-col">
<FormLabel label="Org" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.owner || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Project" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.targetService || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Env" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.targetEnvironment || "-"}
</div>
</div>
</div>
)}
{!(
integration.integration === "aws-secret-manager" &&
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
) && (
<div className="ml-2 flex flex-col">
<FormLabel
label={
(integration.integration === "qovery" && integration?.scope) ||
(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="no-scrollbar::-webkit-scrollbar min-w-[8rem] max-w-[12rem] overflow-scroll whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200 no-scrollbar">
{(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}
</div>
</div>
)}
{(integration.integration === "vercel" ||
integration.integration === "netlify" ||
integration.integration === "railway" ||
integration.integration === "gitlab" ||
integration.integration === "teamcity" ||
integration.integration === "bitbucket" ||
(integration.integration === "github" && integration.scope === "github-env")) && (
<div className="ml-4 flex flex-col">
<FormLabel label="Target Environment" />
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "checkly" && integration.targetService && (
<div className="ml-2">
<FormLabel label="Group" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService}
</div>
</div>
)}
{integration.integration === "terraform-cloud" && integration.targetService && (
<div className="ml-2">
<FormLabel label="Category" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService}
</div>
</div>
)}
{(integration.integration === "checkly" || integration.integration === "github") && (
<div className="ml-2">
<FormLabel label="Secret Suffix" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.metadata?.secretSuffix || "-"}
</div>
</div>
)}
</div>
<div className="mt-[1.5rem] flex cursor-default">
{integration.isSynced != null && integration.lastUsed != null && (
<Tag key={integration.id} className={integration.isSynced ? "bg-green-800" : "bg-red/80"}>
<Tooltip
center
className="max-w-xs whitespace-normal break-words"
content={
<div className="flex max-h-[10rem] flex-col overflow-auto ">
<div className="flex self-start">
<FontAwesomeIcon icon={faCalendarCheck} className="pt-0.5 pr-2 text-sm" />
<div className="text-sm">Last sync</div>
</div>
<div className="pl-5 text-left text-xs">
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")}
</div>
{!integration.isSynced && (
<>
<div className="mt-2 flex self-start">
<FontAwesomeIcon icon={faXmark} className="pt-1 pr-2 text-sm" />
<div className="text-sm">Fail reason</div>
</div>
<div className="pl-5 text-left text-xs">{integration.syncMessage}</div>
</>
)}
</div>
}
>
<div className="flex items-center space-x-2 text-white">
<div>{integration.isSynced ? "Synced" : "Not synced"}</div>
{!integration.isSynced && <FontAwesomeIcon icon={faWarning} />}
</div>
</Tooltip>
</Tag>
)}
<div className="mr-1 flex items-end opacity-80 duration-200 hover:opacity-100">
<Tooltip className="text-center" content="Manually sync integration secrets">
<Button
onClick={() => onManualSyncIntegration()}
className="max-w-[2.5rem] border-none bg-mineshaft-500"
>
<FontAwesomeIcon icon={faRefresh} className="px-1 text-bunker-200" />
</Button>
</Tooltip>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Integrations}
>
{(isAllowed: boolean) => (
<div className="flex items-end opacity-80 duration-200 hover:opacity-100">
<Tooltip content="Remove Integration">
<IconButton
onClick={() => onRemoveIntegration()}
ariaLabel="delete"
isDisabled={!isAllowed}
colorSchema="danger"
variant="star"
>
<FontAwesomeIcon icon={faXmark} className="px-0.5" />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</div>
</div>
);
};
@@ -1,27 +1,10 @@
import { faCalendarCheck } from "@fortawesome/free-regular-svg-icons";
import { faArrowRight, faRefresh, faWarning, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { integrationSlugNameMapping } from "public/data/frequentConstants";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
Checkbox,
DeleteActionModal,
EmptyState,
FormLabel,
IconButton,
Skeleton,
Tag,
Tooltip
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { Checkbox, DeleteActionModal, EmptyState, Skeleton } from "@app/components/v2";
import { usePopUp, useToggle } from "@app/hooks";
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types";
import { TIntegration } from "@app/hooks/api/types";
import { ConfiguredIntegrationItem } from "./ConfiguredIntegrationItem";
type Props = {
environments: Array<{ name: string; slug: string; id: string }>;
integrations?: TIntegration[];
@@ -72,207 +55,22 @@ export const IntegrationsSection = ({
{!isLoading && (
<div className="flex min-w-max flex-col space-y-4 p-6 pt-0">
{integrations?.map((integration) => (
<div
className="max-w-8xl flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3"
key={`integration-${integration?.id.toString()}`}
>
<div className="flex">
<div className="ml-2 flex flex-col">
<FormLabel label="Environment" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{environments.find((e) => e.id === integration.envId)?.name || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Secret Path" />
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.secretPath}
</div>
</div>
<div className="flex h-full items-center">
<FontAwesomeIcon icon={faArrowRight} className="mx-4 text-gray-400" />
</div>
<div className="ml-4 flex flex-col">
<FormLabel label="Integration" />
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integrationSlugNameMapping[integration.integration]}
</div>
</div>
{integration.integration === "qovery" && (
<div className="flex flex-row">
<div className="ml-2 flex flex-col">
<FormLabel label="Org" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.owner || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Project" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.targetService || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Env" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.targetEnvironment || "-"}
</div>
</div>
</div>
)}
{!(
integration.integration === "aws-secret-manager" &&
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
) && (
<div className="ml-2 flex flex-col">
<FormLabel
label={
(integration.integration === "qovery" && integration?.scope) ||
(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="no-scrollbar::-webkit-scrollbar min-w-[8rem] max-w-[12rem] overflow-scroll whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200 no-scrollbar">
{(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}
</div>
</div>
)}
{(integration.integration === "vercel" ||
integration.integration === "netlify" ||
integration.integration === "railway" ||
integration.integration === "gitlab" ||
integration.integration === "teamcity" ||
integration.integration === "bitbucket" ||
(integration.integration === "github" && integration.scope === "github-env")) && (
<div className="ml-4 flex flex-col">
<FormLabel label="Target Environment" />
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "checkly" && integration.targetService && (
<div className="ml-2">
<FormLabel label="Group" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService}
</div>
</div>
)}
{integration.integration === "terraform-cloud" && integration.targetService && (
<div className="ml-2">
<FormLabel label="Category" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService}
</div>
</div>
)}
{(integration.integration === "checkly" ||
integration.integration === "github") && (
<div className="ml-2">
<FormLabel label="Secret Suffix" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.metadata?.secretSuffix || "-"}
</div>
</div>
)}
</div>
<div className="mt-[1.5rem] flex cursor-default">
{integration.isSynced != null && integration.lastUsed != null && (
<Tag
key={integration.id}
className={integration.isSynced ? "bg-green-800" : "bg-red/80"}
>
<Tooltip
center
className="max-w-xs whitespace-normal break-words"
content={
<div className="flex max-h-[10rem] flex-col overflow-auto ">
<div className="flex self-start">
<FontAwesomeIcon
icon={faCalendarCheck}
className="pt-0.5 pr-2 text-sm"
/>
<div className="text-sm">Last sync</div>
</div>
<div className="pl-5 text-left text-xs">
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")}
</div>
{!integration.isSynced && (
<>
<div className="mt-2 flex self-start">
<FontAwesomeIcon icon={faXmark} className="pt-1 pr-2 text-sm" />
<div className="text-sm">Fail reason</div>
</div>
<div className="pl-5 text-left text-xs">
{integration.syncMessage}
</div>
</>
)}
</div>
}
>
<div className="flex items-center space-x-2 text-white">
<div>{integration.isSynced ? "Synced" : "Not synced"}</div>
{!integration.isSynced && <FontAwesomeIcon icon={faWarning} />}
</div>
</Tooltip>
</Tag>
)}
<div className="mr-1 flex items-end opacity-80 duration-200 hover:opacity-100">
<Tooltip className="text-center" content="Manually sync integration secrets">
<Button
onClick={() =>
syncIntegration({
workspaceId,
id: integration.id,
lastUsed: integration.lastUsed as string
})
}
className="max-w-[2.5rem] border-none bg-mineshaft-500"
>
<FontAwesomeIcon icon={faRefresh} className="px-1 text-bunker-200" />
</Button>
</Tooltip>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Integrations}
>
{(isAllowed: boolean) => (
<div className="flex items-end opacity-80 duration-200 hover:opacity-100">
<Tooltip content="Remove Integration">
<IconButton
onClick={() => {
setShouldDeleteSecrets.off();
handlePopUpOpen("deleteConfirmation", integration);
}}
ariaLabel="delete"
isDisabled={!isAllowed}
colorSchema="danger"
variant="star"
>
<FontAwesomeIcon icon={faXmark} className="px-0.5" />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</div>
</div>
<ConfiguredIntegrationItem
key={`integration-${integration.id}`}
onManualSyncIntegration={() => {
syncIntegration({
workspaceId,
id: integration.id,
lastUsed: integration.lastUsed as string
});
}}
onRemoveIntegration={() => {
setShouldDeleteSecrets.off();
handlePopUpOpen("deleteConfirmation", integration);
}}
integration={integration}
environments={environments}
/>
))}
</div>
)}
@@ -1,4 +1,6 @@
import { useState } from "react";
import { DiRedis } from "react-icons/di";
import { SiApachecassandra, SiElasticsearch, SiMongodb } from "react-icons/si";
import { faAws } from "@fortawesome/free-brands-svg-icons";
import { faDatabase } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -10,6 +12,8 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
import { AwsIamInputForm } from "./AwsIamInputForm";
import { CassandraInputForm } from "./CassandraInputForm";
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
import { RedisInputForm } from "./RedisInputForm";
import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm";
@@ -28,29 +32,39 @@ enum WizardSteps {
const DYNAMIC_SECRET_LIST = [
{
icon: faDatabase,
icon: <FontAwesomeIcon icon={faDatabase} size="lg" />,
provider: DynamicSecretProviders.SqlDatabase,
title: "SQL\nDatabase"
},
{
icon: faDatabase,
icon: <SiApachecassandra size="2rem" />,
provider: DynamicSecretProviders.Cassandra,
title: "Cassandra"
},
{
icon: faDatabase,
icon: <DiRedis size="2rem" />,
provider: DynamicSecretProviders.Redis,
title: "Redis"
},
{
icon: faAws,
icon: <FontAwesomeIcon icon={faAws} size="lg" />,
provider: DynamicSecretProviders.AwsElastiCache,
title: "AWS ElastiCache"
},
{
icon: faAws,
icon: <FontAwesomeIcon icon={faAws} size="lg" />,
provider: DynamicSecretProviders.AwsIam,
title: "AWS IAM"
},
{
icon: <SiMongodb size="2rem" />,
provider: DynamicSecretProviders.MongoAtlas,
title: "Mongo Atlas"
},
{
icon: <SiElasticsearch size="2rem" />,
provider: DynamicSecretProviders.ElasticSearch,
title: "Elastic Search"
}
];
@@ -87,7 +101,7 @@ export const CreateDynamicSecretForm = ({
exit={{ opacity: 0, translateX: -30 }}
>
<div className="mb-4 text-mineshaft-300">Select a service to connect to:</div>
<div className="flex items-center space-x-4">
<div className="flex flex-wrap items-center gap-4">
{DYNAMIC_SECRET_LIST.map(({ icon, provider, title }) => (
<div
key={`dynamic-secret-provider-${provider}`}
@@ -105,7 +119,7 @@ export const CreateDynamicSecretForm = ({
}
}}
>
<FontAwesomeIcon icon={icon} size="lg" />
{icon}
<div className="whitespace-pre-wrap text-center text-sm">{title}</div>
</div>
))}
@@ -202,6 +216,42 @@ export const CreateDynamicSecretForm = ({
/>
</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>
)}
</AnimatePresence>
</ModalContent>
</Modal>
@@ -0,0 +1,425 @@
import { Controller, useForm } from "react-hook-form";
import Link from "next/link";
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.isLoading) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.ElasticSearch, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch (err) {
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{" "}
<Link
passHref
href="https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-roles.html"
>
<a target="_blank" rel="noopener noreferrer">
<span className="cursor-pointer text-primary-400">
read more about roles here
</span>
</a>
</Link>
.
</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,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.isLoading) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.MongoAtlas, inputs: provider },
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment
});
onCompleted();
} catch (err) {
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>
);
};
@@ -57,7 +57,8 @@ const OutputDisplay = ({
const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
if (
provider === DynamicSecretProviders.SqlDatabase ||
provider === DynamicSecretProviders.Cassandra
provider === DynamicSecretProviders.Cassandra ||
provider === DynamicSecretProviders.MongoAtlas
) {
const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string };
return (
@@ -138,6 +139,24 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
);
}
if (provider === DynamicSecretProviders.ElasticSearch) {
const { DB_USERNAME, DB_PASSWORD } = data as {
DB_USERNAME: string;
DB_PASSWORD: string;
};
return (
<div>
<OutputDisplay label="Username" value={DB_USERNAME} />
<OutputDisplay
label="Password"
value={DB_PASSWORD}
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
/>
</div>
);
}
return null;
};
@@ -0,0 +1,429 @@
import { Controller, useForm } from "react-hook-form";
import Link from "next/link";
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 { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } 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({
inputs: 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" });
}),
newName: z
.string()
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.optional()
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecret: TDynamicSecret & { inputs: unknown };
secretPath: string;
environment: string;
projectSlug: string;
};
export const EditDynamicSecretElasticSearchForm = ({
onClose,
dynamicSecret,
secretPath,
environment,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit,
setValue,
watch
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"])
}
}
});
const updateDynamicSecret = useUpdateDynamicSecret();
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isLoading) return;
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
path: secretPath,
projectSlug,
environmentSlug: environment,
data: {
maxTTL: maxTTL || undefined,
defaultTTL,
inputs,
newName: newName === dynamicSecret.name ? undefined : newName
}
});
onClose();
createNotification({
type: "success",
text: "Successfully updated dynamic secret"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to update dynamic secret"
});
}
};
const selectedAuthType = watch("inputs.auth.type");
const selectedRoles = watch("inputs.roles");
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="newName"
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="inputs.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="inputs.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="inputs.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 = [
"inputs.auth.username",
"inputs.auth.password",
"inputs.auth.apiKey",
"inputs.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" ? "inputs.auth.username" : "inputs.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" ? "inputs.auth.password" : "inputs.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="relative mb-3 flex flex-col">
<FormLabel
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{" "}
<Link
passHref
href="https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-roles.html"
>
<a target="_blank" rel="noopener noreferrer">
<span className="cursor-pointer text-primary-400">
read more about roles here
</span>
</a>
</Link>
.
</p>
<p>
You can also assign custom roles by providing the name of the custom role in
the input field.
</p>
</div>
}
/>
{selectedRoles.map((_, i) => (
<Controller
control={control}
name={`inputs.roles.${i}`}
// eslint-disable-next-line react/no-array-index-key
key={`role-${i}`}
render={({ field, fieldState: { error } }) => (
<div className="flex items-center gap-2">
<div className="flex-grow">
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Input
placeholder="Insert role name, (superuser, kibana_admin, custom_role)"
className="mb-0 flex-grow"
{...field}
/>
</FormControl>
</div>
<IconButton
isDisabled={selectedRoles.length === 1}
ariaLabel="delete key"
className="bottom-2 h-9"
variant="outline_bg"
onClick={() => {
if (selectedRoles && selectedRoles?.length > 1) {
setValue(
"inputs.roles",
selectedRoles.filter((__, idx) => idx !== i)
);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
)}
/>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
className="bottom-2"
variant="outline_bg"
onClick={() => {
setValue("inputs.roles", [...selectedRoles, ""]);
}}
>
Add Role
</Button>
</div>
</div>
<div>
<Controller
control={control}
name="inputs.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={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -7,6 +7,8 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm";
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
import { EditDynamicSecretRedisProviderForm } from "./EditDynamicSecretRedisProviderForm";
import { EditDynamicSecretSqlProviderForm } from "./EditDynamicSecretSqlProviderForm";
@@ -128,6 +130,41 @@ export const EditDynamicSecretForm = ({
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.MongoAtlas && (
<motion.div
key="mongo-atlas-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretMongoAtlasForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.ElasticSearch && (
<motion.div
key="elastic-search-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretElasticSearchForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,466 @@
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 { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
inputs: 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()
})
.partial(),
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" });
})
.nullable(),
newName: z
.string()
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.optional()
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecret: TDynamicSecret & { inputs: unknown };
secretPath: string;
environment: string;
projectSlug: string;
};
const ATLAS_SCOPE_TYPES = [
{
label: "Cluster",
value: "CLUSTER"
},
{
label: "Data Lake",
value: "DATA_LAKE"
},
{
label: "Stream",
value: "STREAM"
}
];
export const EditDynamicSecretMongoAtlasForm = ({
onClose,
dynamicSecret,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
getValues,
setValue,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"])
}
}
});
const roleFields = useFieldArray({
control,
name: "inputs.roles"
});
const scopeFields = useFieldArray({
control,
name: "inputs.scopes"
});
const updateDynamicSecret = useUpdateDynamicSecret();
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isLoading) return;
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
path: secretPath,
projectSlug,
environmentSlug: environment,
data: {
maxTTL: maxTTL || undefined,
defaultTTL,
inputs,
newName: newName === dynamicSecret.name ? undefined : newName
}
});
onClose();
createNotification({
type: "success",
text: "Successfully updated dynamic secret"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to update dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
name="newName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="DYN-1" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
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"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</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="inputs.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="inputs.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="inputs.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={`inputs.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={`inputs.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={`inputs.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("inputs.roles");
if (roles && roles?.length > 1) {
roleFields.remove(i);
} else {
setValue("inputs.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={`inputs.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={`inputs.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 className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Save
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -32,7 +32,8 @@ const viewLimitOptions = [
const schema = z.object({
name: z.string().optional(),
secret: z.string(),
password: z.string().optional(),
secret: z.string().min(1),
expiresIn: z.string(),
viewLimit: z.string(),
accessType: z.nativeEnum(SecretSharingAccessType).optional()
@@ -67,7 +68,14 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
}
});
const onFormSubmit = async ({ name, secret, expiresIn, viewLimit, accessType }: FormData) => {
const onFormSubmit = async ({
name,
password,
secret,
expiresIn,
viewLimit,
accessType
}: FormData) => {
try {
const expiresAt = new Date(new Date().getTime() + Number(expiresIn));
@@ -80,6 +88,7 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
const { id } = await createSharedSecret.mutateAsync({
name,
password,
encryptedValue: ciphertext,
hashedHex,
iv,
@@ -149,6 +158,20 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error)}
errorText={error?.message}
isOptional
>
<Input {...field} placeholder="Password" type="password" />
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
@@ -1,26 +1,43 @@
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AxiosError } from "axios";
import { useGetActiveSharedSecretById } from "@app/hooks/api/secretSharing";
import { SecretContainer, SecretErrorContainer } from "./components";
import { PasswordContainer,SecretContainer, SecretErrorContainer } from "./components";
export const ViewSecretPublicPage = () => {
const router = useRouter();
const [password, setPassword] = useState<string>();
const { id, key: urlEncodedPublicKey } = router.query;
const [hashedHex, key] = urlEncodedPublicKey
? urlEncodedPublicKey.toString().split("-")
: ["", ""];
const { data: secret, error } = useGetActiveSharedSecretById({
const {
data: fetchSecret,
error,
isLoading,
isFetching
} = useGetActiveSharedSecretById({
sharedSecretId: id as string,
hashedHex
hashedHex,
password
});
const isInvalidCredential =
((error as AxiosError)?.response?.data as { message: string })?.message ===
"Invalid credentials";
const shouldShowPasswordPrompt =
isInvalidCredential || (fetchSecret?.isPasswordProtected && !fetchSecret.secret);
const isValidatingPassword = Boolean(password) && isFetching;
return (
<div className="flex h-screen flex-col justify-between overflow-auto bg-gradient-to-tr from-mineshaft-700 to-bunker-800 text-gray-200 dark:[color-scheme:dark]">
<div />
@@ -52,8 +69,23 @@ export const ViewSecretPublicPage = () => {
</a>
</p>
</div>
{secret && key && <SecretContainer secret={secret} secretKey={key} />}
{error && <SecretErrorContainer />}
{(shouldShowPasswordPrompt || isValidatingPassword) && (
<PasswordContainer
isSubmitting={isValidatingPassword}
onPasswordSubmit={(el) => {
setPassword(el);
}}
isInvalidCredential={!isFetching && isInvalidCredential}
/>
)}
{!isLoading && (
<>
{!error && fetchSecret?.secret && key && (
<SecretContainer secret={fetchSecret.secret} secretKey={key} />
)}
{error && !isInvalidCredential && <SecretErrorContainer />}
</>
)}
<div className="m-auto my-8 flex w-full">
<div className="w-full border-t border-mineshaft-600" />
</div>
@@ -0,0 +1,80 @@
import { Controller, useForm } from "react-hook-form";
import { faArrowRight, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, IconButton, Input } from "@app/components/v2";
type Props = {
onPasswordSubmit: (val: any) => void;
isSubmitting?: boolean;
isInvalidCredential?: boolean;
};
const formSchema = z.object({
password: z.string()
});
export type FormData = z.infer<typeof formSchema>;
export const PasswordContainer = ({
onPasswordSubmit,
isSubmitting,
isInvalidCredential
}: Props) => {
const { control, handleSubmit } = useForm<FormData>({
resolver: zodResolver(formSchema)
});
const onFormSubmit = async ({ password }: FormData) => {
onPasswordSubmit(password);
};
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="password"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error) || isInvalidCredential}
errorText={isInvalidCredential ? "Invalid credential" : error?.message}
isRequired
label="Password"
>
<div className="flex items-center justify-between gap-2 rounded-md">
<Input {...field} placeholder="Enter Password to view secret" type="password" />
<div className="flex">
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={handleSubmit(onFormSubmit)}
>
<FontAwesomeIcon
className={isSubmitting ? "fa-spin" : ""}
icon={isSubmitting ? faSpinner : faArrowRight}
/>
</IconButton>
</div>
</div>
</FormControl>
)}
/>
</form>
<Button
className="w-full bg-mineshaft-700 py-3 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="sm"
onClick={() => window.open("https://app.infisical.com/share-secret", "_blank")}
rightIcon={<FontAwesomeIcon icon={faArrowRight} className="pl-2" />}
>
Share your own secret
</Button>
</div>
);
};
@@ -14,7 +14,7 @@ import { useTimedReset, useToggle } from "@app/hooks";
import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing";
type Props = {
secret: TViewSharedSecretResponse;
secret: TViewSharedSecretResponse["secret"];
secretKey: string;
};
@@ -1,2 +1,3 @@
export { PasswordContainer } from "./PasswordContainer";
export { SecretContainer } from "./SecretContainer";
export { SecretErrorContainer } from "./SecretErrorContainer";