Merge remote-tracking branch 'origin' into groups-phase-2b

This commit is contained in:
Tuan Dang
2024-04-22 12:08:10 -07:00
84 changed files with 4421 additions and 239 deletions
+32 -15
View File
@@ -16,7 +16,8 @@ export type TDynamicSecret = {
};
export enum DynamicSecretProviders {
SqlDatabase = "sql-database"
SqlDatabase = "sql-database",
Cassandra = "cassandra"
}
export enum SqlProviders {
@@ -25,21 +26,37 @@ export enum SqlProviders {
Oracle = "oracledb"
}
export type TDynamicSecretProvider = {
type: DynamicSecretProviders;
inputs: {
client: SqlProviders;
host: string;
port: number;
database: string;
username: string;
password: string;
creationStatement: string;
revocationStatement: string;
renewStatement?: string;
ca?: string | undefined;
export type TDynamicSecretProvider =
| {
type: DynamicSecretProviders.SqlDatabase;
inputs: {
client: SqlProviders;
host: string;
port: number;
database: string;
username: string;
password: string;
creationStatement: string;
revocationStatement: string;
renewStatement?: string;
ca?: string | undefined;
};
}
| {
type: DynamicSecretProviders.Cassandra;
inputs: {
host: string;
port: number;
keyspace?: string;
localDataCenter: string;
username: string;
password: string;
creationStatement: string;
revocationStatement: string;
renewStatement?: string;
ca?: string | undefined;
};
};
};
export type TCreateDynamicSecretDTO = {
projectSlug: string;
@@ -10,6 +10,7 @@ import {
Environment,
HerokuPipelineCoupling,
IntegrationAuth,
KmsKey,
NorthflankSecretGroup,
Org,
Project,
@@ -43,6 +44,14 @@ const integrationAuthKeys = {
[{ integrationAuthId }, "integrationAuthGithubOrgs"] as const,
getIntegrationAuthGithubEnvs: (integrationAuthId: string, repoName: string, repoOwner: string) =>
[{ integrationAuthId, repoName, repoOwner }, "integrationAuthGithubOrgs"] as const,
getIntegrationAuthAwsKmsKeys: ({
integrationAuthId,
region
}: {
integrationAuthId: string,
region: string
}) =>
[{ integrationAuthId, region }, "integrationAuthAwsKmsKeyIds"] as const,
getIntegrationAuthQoveryOrgs: (integrationAuthId: string) =>
[{ integrationAuthId }, "integrationAuthQoveryOrgs"] as const,
getIntegrationAuthQoveryProjects: ({
@@ -217,6 +226,27 @@ const fetchIntegrationAuthQoveryOrgs = async (integrationAuthId: string) => {
return orgs;
};
const fetchIntegrationAuthAwsKmsKeys = async ({
integrationAuthId,
region
}: {
integrationAuthId: string;
region: string;
}) => {
const {
data: { kmsKeys }
} = await apiRequest.get<{ kmsKeys: KmsKey[] }>(
`/api/v1/integration-auth/${integrationAuthId}/aws-secrets-manager/kms-keys`,
{
params: {
region
}
}
);
return kmsKeys;
};
const fetchIntegrationAuthQoveryProjects = async ({
integrationAuthId,
orgId
@@ -544,6 +574,27 @@ export const useGetIntegrationAuthQoveryOrgs = (integrationAuthId: string) => {
});
};
export const useGetIntegrationAuthAwsKmsKeys = ({
integrationAuthId,
region
}: {
integrationAuthId: string;
region: string;
}) => {
return useQuery({
queryKey: integrationAuthKeys.getIntegrationAuthAwsKmsKeys({
integrationAuthId,
region
}),
queryFn: () =>
fetchIntegrationAuthAwsKmsKeys({
integrationAuthId,
region
}),
enabled: true
});
};
export const useGetIntegrationAuthQoveryProjects = ({
integrationAuthId,
orgId
@@ -58,6 +58,11 @@ export type Project = {
projectId: string;
};
export type KmsKey = {
id: string;
alias: string;
};
export type Service = {
name: string;
serviceId: string;
@@ -66,7 +66,8 @@ export const useCreateIntegration = () => {
secretAWSTag?: {
key: string;
value: string;
};
}[];
kmsKeyId?: string;
};
}) => {
const {
@@ -128,10 +128,10 @@ export default function AWSParameterStoreCreateIntegrationPage() {
metadata: {
...(shouldTag
? {
secretAWSTag: {
secretAWSTag: [{
key: tagKey,
value: tagValue
}
}]
}
: {})
}
@@ -279,7 +279,7 @@ export default function AWSParameterStoreCreateIntegrationPage() {
label="Tag Value"
>
<Input
placeholder="managed-by"
placeholder="infisical"
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
@@ -14,6 +14,7 @@ import { motion } from "framer-motion";
import queryString from "query-string";
import { useCreateIntegration } from "@app/hooks/api";
import { useGetIntegrationAuthAwsKmsKeys } from "@app/hooks/api/integrationAuth/queries";
import {
Button,
@@ -87,6 +88,7 @@ export default function AWSSecretManagerCreateIntegrationPage() {
const [targetSecretNameErrorText, setTargetSecretNameErrorText] = useState("");
const [tagKey, setTagKey] = useState("");
const [tagValue, setTagValue] = useState("");
const [kmsKeyId, setKmsKeyId] = useState("");
// const [path, setPath] = useState('');
// const [pathErrorText, setPathErrorText] = useState('');
@@ -94,6 +96,19 @@ export default function AWSSecretManagerCreateIntegrationPage() {
const [isLoading, setIsLoading] = useState(false);
const [shouldTag, setShouldTag] = useState(false);
const { data: integrationAuthAwsKmsKeys, isLoading: isIntegrationAuthAwsKmsKeysLoading } =
useGetIntegrationAuthAwsKmsKeys({
integrationAuthId: String(integrationAuthId),
region: selectedAWSRegion
});
useEffect(() => {
if (integrationAuthAwsKmsKeys) {
setKmsKeyId(String(integrationAuthAwsKmsKeys?.filter(key => key.alias === "alias/aws/secretsmanager")[0]?.id))
}
}, [integrationAuthAwsKmsKeys])
useEffect(() => {
if (workspace) {
setSelectedSourceEnvironment(workspace.environments[0].slug);
@@ -127,12 +142,16 @@ export default function AWSSecretManagerCreateIntegrationPage() {
metadata: {
...(shouldTag
? {
secretAWSTag: {
secretAWSTag: [{
key: tagKey,
value: tagValue
}
}]
}
: {})
: {}),
...((kmsKeyId && integrationAuthAwsKmsKeys?.filter(key => key.id === kmsKeyId)[0]?.alias !== "alias/aws/secretsmanager") ?
{
kmsKeyId
}: {})
}
});
@@ -145,7 +164,7 @@ export default function AWSSecretManagerCreateIntegrationPage() {
}
};
return integrationAuth && workspace && selectedSourceEnvironment ? (
return (integrationAuth && workspace && selectedSourceEnvironment && !isIntegrationAuthAwsKmsKeysLoading) ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<Head>
<title>Set Up AWS Secrets Manager Integration</title>
@@ -278,13 +297,38 @@ export default function AWSSecretManagerCreateIntegrationPage() {
label="Tag Value"
>
<Input
placeholder="managed-by"
placeholder="infisical"
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</FormControl>
</div>
)}
<FormControl label="Encryption Key" className="mt-4">
<Select
value={kmsKeyId}
onValueChange={(e) => {
setKmsKeyId(e)
}}
className="w-full border border-mineshaft-500"
>
{integrationAuthAwsKmsKeys?.length ? (
integrationAuthAwsKmsKeys.map((key) => {
return (
<SelectItem
value={key.id as string}
key={`repo-id-${key.id}`}
className="w-[28.4rem] text-sm"
>
{key.alias}
</SelectItem>
);
})
) : (
<div />
)}
</Select>
</FormControl>
</motion.div>
</TabPanel>
</Tabs>
@@ -317,7 +361,7 @@ export default function AWSSecretManagerCreateIntegrationPage() {
<title>Set Up AWS Secrets Manager Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
{isintegrationAuthLoading ? (
{(isintegrationAuthLoading || isIntegrationAuthAwsKmsKeysLoading) ? (
<img
src="/images/loading/loading.gif"
height={70}
@@ -1,5 +1,10 @@
import { useState } from "react";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSaveIntegrationAccessToken } from "@app/hooks/api";
@@ -49,12 +54,56 @@ export default function TerraformCloudCreateIntegrationPage() {
return (
<div className="flex h-full w-full items-center justify-center">
<Card className="max-w-md rounded-md p-8">
<CardTitle className="text-center">Terraform Cloud Integration</CardTitle>
<Head>
<title>Authorize Terraform Cloud Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<Card className="max-w-lg rounded-md border border-mineshaft-600">
<CardTitle
className="px-6 text-left text-xl"
subTitle="After adding the details below, you will be prompted to set up an integration for a particular Infisical project and environment."
>
<div className="flex flex-row items-center">
<div className="inline flex items-center">
<Image
src="/images/integrations/Terraform.png"
height={35}
width={35}
alt="Terraform logo"
/>
</div>
<span className="ml-1.5">Terraform Cloud Integration </span>
<Link href="https://infisical.com/docs/integrations/cloud/terraform-cloud" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 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="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
</CardTitle>
<FormControl
label="Terraform Cloud Workspace ID"
errorText={workspacesIdErrorText}
isError={workspacesIdErrorText !== "" ?? false}
className="px-6"
>
<Input
placeholder="Workspace Id"
value={workspacesId}
onChange={(e) => setWorkSpacesId(e.target.value)}
/>
</FormControl>
<FormControl
label="Terraform Cloud API Token"
errorText={apiKeyErrorText}
isError={apiKeyErrorText !== "" ?? false}
className="px-6"
>
<Input
placeholder="API Token"
@@ -64,21 +113,11 @@ export default function TerraformCloudCreateIntegrationPage() {
onChange={(e) => setApiKey(e.target.value)}
/>
</FormControl>
<FormControl
label="Terraform Cloud Workspace ID"
errorText={workspacesIdErrorText}
isError={workspacesIdErrorText !== "" ?? false}
>
<Input
placeholder="Workspace Id"
value={workspacesId}
onChange={(e) => setWorkSpacesId(e.target.value)}
/>
</FormControl>
<Button
onClick={handleButtonClick}
color="mineshaft"
className="mt-4"
colorSchema="primary"
variant="outline_bg"
className="mb-6 mt-2 ml-auto mr-6 w-min"
isLoading={isLoading}
>
Connect to Terraform Cloud
@@ -1,8 +1,14 @@
import { useEffect, useState } from "react";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import queryString from "query-string";
import { useCreateIntegration } from "@app/hooks/api";
import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types";
import {
Button,
@@ -19,6 +25,15 @@ import {
} from "../../../hooks/api/integrationAuth";
import { useGetWorkspaceById } from "../../../hooks/api/workspace";
const initialSyncBehaviors = [
{
label: "No Import - Overwrite all values in Terraform Cloud",
value: IntegrationSyncBehavior.OVERWRITE_TARGET
},
{ label: "Import non-sensitive - Prefer values from Terraform Cloud", value: IntegrationSyncBehavior.PREFER_TARGET },
{ label: "Import non-sensitive - Prefer values from Infisical", value: IntegrationSyncBehavior.PREFER_SOURCE }
];
const variableTypes = [{ name: "env" }, { name: "terraform" }];
export default function TerraformCloudCreateIntegrationPage() {
@@ -39,6 +54,7 @@ export default function TerraformCloudCreateIntegrationPage() {
const [variableType, setVariableType] = useState("");
const [variableTypeErrorText, setVariableTypeErrorText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [initialSyncBehavior, setInitialSyncBehavior] = useState("prefer-source")
useEffect(() => {
if (workspace) {
@@ -78,7 +94,10 @@ export default function TerraformCloudCreateIntegrationPage() {
)?.appId,
sourceEnvironment: selectedSourceEnvironment,
targetService: variableType,
secretPath
secretPath,
metadata: {
initialSyncBehavior
}
});
setIsLoading(false);
@@ -95,9 +114,40 @@ export default function TerraformCloudCreateIntegrationPage() {
integrationAuthApps &&
targetApp ? (
<div className="flex h-full w-full items-center justify-center">
<Card className="max-w-md rounded-md p-8">
<CardTitle className="text-center">Terraform Cloud Integration</CardTitle>
<FormControl label="Project Environment" className="mt-4">
<Head>
<title>Create Terraform Cloud Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<Card className="max-w-lg rounded-md border border-mineshaft-600">
<CardTitle
className="px-6 text-left text-xl"
subTitle="Specify the encironment and path within Infisical that you want to push to which project in Terraform."
>
<div className="flex flex-row items-center">
<div className="inline flex items-center">
<Image
src="/images/integrations/Terraform.png"
height={35}
width={35}
alt="Terraform logo"
/>
</div>
<span className="ml-1.5">Terraform Cloud Integration </span>
<Link href="https://infisical.com/docs/integrations/cloud/terraform-cloud" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 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="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
</CardTitle>
<FormControl label="Project Environment" className="px-6">
<Select
value={selectedSourceEnvironment}
onValueChange={(val) => setSelectedSourceEnvironment(val)}
@@ -113,7 +163,7 @@ export default function TerraformCloudCreateIntegrationPage() {
))}
</Select>
</FormControl>
<FormControl label="Secrets Path">
<FormControl label="Secrets Path" className="px-6">
<Input
value={secretPath}
onChange={(evt) => setSecretPath(evt.target.value)}
@@ -122,7 +172,7 @@ export default function TerraformCloudCreateIntegrationPage() {
</FormControl>
<FormControl
label="Category"
className="mt-4"
className="px-6"
errorText={variableTypeErrorText}
isError={variableTypeErrorText !== "" ?? false}
>
@@ -138,7 +188,7 @@ export default function TerraformCloudCreateIntegrationPage() {
))}
</Select>
</FormControl>
<FormControl label="Terraform Cloud Project" className="mt-4">
<FormControl label="Terraform Cloud Project" className="px-6">
<Select
value={targetApp}
onValueChange={(val) => setTargetApp(val)}
@@ -161,10 +211,22 @@ export default function TerraformCloudCreateIntegrationPage() {
)}
</Select>
</FormControl>
<FormControl label="Initial Sync Behavior" className="px-6">
<Select value={initialSyncBehavior} onValueChange={(e) => setInitialSyncBehavior(e)} className="w-full border border-mineshaft-600">
{initialSyncBehaviors.map((b) => {
return (
<SelectItem value={b.value} key={`sync-behavior-${b.value}`}>
{b.label}
</SelectItem>
);
})}
</Select>
</FormControl>
<Button
onClick={handleButtonClick}
color="mineshaft"
className="mt-4"
colorSchema="primary"
variant="outline_bg"
className="mb-6 mt-2 ml-auto mr-6 w-min"
isLoading={isLoading}
isDisabled={integrationAuthApps.length === 0}
>
@@ -74,7 +74,7 @@ export const IntegrationsSection = ({
</div>
)}
{!isLoading && isBotActive && (
<div className="flex flex-col space-y-4 p-6 pt-0">
<div className="flex flex-col min-w-max 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"
@@ -128,6 +128,9 @@ export const IntegrationsSection = ({
<FormLabel
label={
(integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "aws-secret-manager" && "Secret") ||
(integration.integration === "aws-parameter-store" && "Path") ||
(integration?.integration === "terraform-cloud" && "Project") ||
(integration?.scope === "github-org" && "Organization") ||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
"Repository") ||
@@ -138,6 +141,7 @@ export const IntegrationsSection = ({
{(integration.integration === "hashicorp-vault" &&
`${integration.app} - path: ${integration.path}`) ||
(integration.scope === "github-org" && `${integration.owner}`) ||
(integration.integration === "aws-parameter-store" && `${integration.path}`) ||
(integration.scope?.startsWith("github-") &&
`${integration.owner}/${integration.app}`) ||
integration.app}
@@ -165,6 +169,14 @@ export const IntegrationsSection = ({
</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">
@@ -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.isLoading) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Cassandra, 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-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>
);
};
@@ -6,6 +6,7 @@ import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { CassandraInputForm } from "./CassandraInputForm";
import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm";
type Props = {
@@ -21,6 +22,19 @@ enum WizardSteps {
ProviderInputs = "provider-inputs"
}
const DYNAMIC_SECRET_LIST = [
{
icon: faDatabase,
provider: DynamicSecretProviders.SqlDatabase,
title: "SQL\nDatabase"
},
{
icon: faDatabase,
provider: DynamicSecretProviders.Cassandra,
title: "Cassandra"
}
];
export const CreateDynamicSecretForm = ({
isOpen,
onToggle,
@@ -55,35 +69,34 @@ export const CreateDynamicSecretForm = ({
>
<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 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(DynamicSecretProviders.SqlDatabase);
setWizardStep(WizardSteps.ProviderInputs);
}}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
setSelectedProvider(DynamicSecretProviders.SqlDatabase);
{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);
}
}}
>
<FontAwesomeIcon icon={faDatabase} size="lg" />
<div className="text-center text-sm">
SQL
<br />
Database
}}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
setSelectedProvider(provider);
setWizardStep(WizardSteps.ProviderInputs);
}
}}
>
<FontAwesomeIcon icon={icon} size="lg" />
<div className="whitespace-pre-wrap text-center text-sm">{title}</div>
</div>
</div>
))}
</div>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.SqlDatabase && (
<motion.div
key="select-input-step"
key="dynamic-sql-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
@@ -98,6 +111,24 @@ export const CreateDynamicSecretForm = ({
/>
</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>
)}
</AnimatePresence>
</ModalContent>
</Modal>
@@ -141,7 +141,7 @@ export const SqlDatabaseInputForm = ({
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)}>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
@@ -265,7 +265,7 @@ export const SqlDatabaseInputForm = ({
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
@@ -278,7 +278,7 @@ export const SqlDatabaseInputForm = ({
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" />
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
@@ -55,7 +55,10 @@ const OutputDisplay = ({
const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string };
if (provider === DynamicSecretProviders.SqlDatabase) {
if (
provider === DynamicSecretProviders.SqlDatabase ||
provider === DynamicSecretProviders.Cassandra
) {
return (
<div>
<OutputDisplay label="Database User" value={DB_USERNAME} />
@@ -102,7 +105,6 @@ export const CreateDynamicSecretLease = ({
ttl: "1h"
}
});
const createDynamicSecretLease = useCreateDynamicSecretLease();
@@ -0,0 +1,374 @@
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 { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
inputs: 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()
})
.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;
};
export const EditDynamicSecretCassandraForm = ({
onClose,
dynamicSecret,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
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 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 border-b border-b-mineshaft-600 pb-2">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={9042}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
type="number"
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="inputs.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="inputs.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="inputs.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="inputs.keyspace"
defaultValue="default"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Keyspace"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</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>
)}
/>
<Accordion type="multiple" className="w-full bg-mineshaft-700">
<AccordionItem value="modify-sql-statement">
<AccordionTrigger>Modify CQL Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="inputs.creationStatement"
defaultValue={
"CREATE ROLE '{{username}}' WITH PASSWORD '{{password}}' AND LOGIN=true;\nGRANT ALL PERMISSIONS ON ALL KEYSPACES TO '{{username}}';"
}
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="inputs.revocationStatement"
defaultValue='DROP ROLE "{{username}}";'
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="inputs.renewStatement"
defaultValue=""
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 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>
);
};
@@ -4,6 +4,7 @@ import { Spinner } from "@app/components/v2";
import { useGetDynamicSecretDetails } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretSqlProviderForm } from "./EditDynamicSecretSqlProviderForm";
type Props = {
@@ -56,6 +57,23 @@ export const EditDynamicSecretForm = ({
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.Cassandra && (
<motion.div
key="cassandra-provider-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretCassandraForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -128,7 +128,7 @@ export const EditDynamicSecretSqlProviderForm = ({
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)}>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
@@ -244,7 +244,7 @@ export const EditDynamicSecretSqlProviderForm = ({
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
@@ -257,7 +257,7 @@ export const EditDynamicSecretSqlProviderForm = ({
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" />
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>