Merge remote-tracking branch 'origin/main' into feat-secret-input-autocomplete

This commit is contained in:
Sheen Capadngan
2024-04-23 11:02:45 +08:00
939 changed files with 30576 additions and 10095 deletions
@@ -31,6 +31,7 @@ const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
export enum PopUpNames {
CreateSecretForm = "create-secret-form"
}
type PopUpState = {
popUp: Record<string, { isOpen: boolean; data?: any }>;
popUpActions: {
@@ -5,8 +5,8 @@ import { subject } from "@casl/ability";
import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import NavHeader from "@app/components/navigation/NavHeader";
import { createNotification } from "@app/components/notifications";
import { PermissionDeniedBanner } from "@app/components/permissions";
import { ContentLoader } from "@app/components/v2";
import {
@@ -17,7 +17,8 @@ import {
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useGetImportedSecrets,
useGetDynamicSecrets,
useGetImportedSecretsSingleEnv,
useGetProjectFolders,
useGetProjectSecrets,
useGetSecretApprovalPolicyOfABoard,
@@ -31,6 +32,7 @@ import {
import { ProjectIndexSecretsSection } from "../SecretOverviewPage/components/ProjectIndexSecretsSection";
import { ActionBar } from "./components/ActionBar";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { DynamicSecretListView } from "./components/DynamicSecretListView";
import { FolderListView } from "./components/FolderListView";
import { PitDrawer } from "./components/PitDrawer";
import { SecretDropzone } from "./components/SecretDropzone";
@@ -51,7 +53,7 @@ export const SecretMainPage = () => {
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
const router = useRouter();
const { permission } = useProjectPermission();
const { createNotification } = useNotificationContext();
const [isVisible, setIsVisible] = useState(false);
const [sortDir, setSortDir] = useState<SortDir>(SortDir.ASC);
@@ -67,6 +69,7 @@ export const SecretMainPage = () => {
// env slug
const environment = router.query.env as string;
const workspaceId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || "";
const secretPath = (router.query.secretPath as string) || "/";
const canReadSecret = permission.can(
ProjectPermissionActions.Read,
@@ -103,12 +106,14 @@ export const SecretMainPage = () => {
enabled: canReadSecret
}
});
// fetch folders
const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({
projectId: workspaceId,
environment,
path: secretPath
});
// fetch secret imports
const {
data: secretImports,
@@ -124,7 +129,7 @@ export const SecretMainPage = () => {
});
// fetch imported secrets to show user the overriden ones
const { data: importedSecrets } = useGetImportedSecrets({
const { data: importedSecrets } = useGetImportedSecretsSingleEnv({
projectId: workspaceId,
environment,
decryptFileKey: decryptFileKey!,
@@ -133,6 +138,13 @@ export const SecretMainPage = () => {
enabled: canReadSecret
}
});
const { data: dynamicSecrets, isLoading: isDynamicSecretLoading } = useGetDynamicSecrets({
projectSlug,
environmentSlug: environment,
path: secretPath
});
// fech tags
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
@@ -163,7 +175,9 @@ export const SecretMainPage = () => {
isPaused: !canDoReadRollback
});
const isNotEmtpy = Boolean(secrets?.length || folders?.length || secretImports?.length);
const isNotEmtpy = Boolean(
secrets?.length || folders?.length || secretImports?.length || dynamicSecrets?.length
);
const handleSortToggle = () =>
setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC));
@@ -213,7 +227,8 @@ export const SecretMainPage = () => {
// loading screen when u have permission
const loadingOnAccess =
canReadSecret && (isSecretsLoading || isSecretImportsLoading || isFoldersLoading);
canReadSecret &&
(isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading);
// loading screen when you don't have permission but as folder's is viewable need to wait for that
const loadingOnDenied = !canReadSecret && isFoldersLoading;
if (loadingOnAccess || loadingOnDenied) {
@@ -244,6 +259,7 @@ export const SecretMainPage = () => {
importedSecrets={importedSecrets}
environment={environment}
workspaceId={workspaceId}
projectSlug={projectSlug}
secretPath={secretPath}
isVisible={isVisible}
filter={filter}
@@ -297,7 +313,17 @@ export const SecretMainPage = () => {
workspaceId={workspaceId}
secretPath={secretPath}
sortDir={sortDir}
searchTerm={filter.searchFilter}
/>
{canReadSecret && (
<DynamicSecretListView
sortDir={sortDir}
environment={environment}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecrets={dynamicSecrets || []}
/>
)}
{canReadSecret && (
<SecretListView
secrets={secrets}
@@ -9,6 +9,7 @@ import {
faEyeSlash,
faFileImport,
faFilter,
faFingerprint,
faFolderPlus,
faMagnifyingGlass,
faMinusSquare,
@@ -19,7 +20,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import FileSaver from "file-saver";
import { twMerge } from "tailwind-merge";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
@@ -52,6 +53,7 @@ import {
useSelectedSecrets
} from "../../SecretMainPage.store";
import { Filter, GroupBy } from "../../SecretMainPage.types";
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm";
import { FolderForm } from "./FolderForm";
@@ -60,7 +62,9 @@ type Props = {
// swtich the secrets type as it gets decrypted after api call
importedSecrets?: Array<Omit<TImportedSecrets, "secrets"> & { secrets: DecryptedSecret[] }>;
environment: string;
// @depreciated will be moving all these details to zustand
workspaceId: string;
projectSlug: string;
secretPath?: string;
filter: Filter;
tags?: WsTag[];
@@ -79,6 +83,7 @@ export const ActionBar = ({
importedSecrets = [],
environment,
workspaceId,
projectSlug,
secretPath = "/",
filter,
tags = [],
@@ -93,13 +98,13 @@ export const ActionBar = ({
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder",
"addDynamicSecret",
"addSecretImport",
"bulkDeleteSecrets",
"misc",
"upgradePlan"
] as const);
const { subscription } = useSubscription();
const { createNotification } = useNotificationContext();
const { openPopUp } = usePopUpAction();
const { mutateAsync: createFolder } = useCreateFolder();
@@ -311,7 +316,6 @@ export const ActionBar = ({
</Button>
)}
</ProjectPermissionCan>
<DropdownMenu
open={popUp.misc.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
@@ -333,14 +337,14 @@ export const ActionBar = ({
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
leftIcon={<FontAwesomeIcon icon={faFolderPlus} className="pr-2" />}
onClick={() => {
handlePopUpOpen("addFolder");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10"
className="h-10 text-left"
isFullWidth
>
Add Folder
@@ -353,13 +357,37 @@ export const ActionBar = ({
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFileImport} />}
leftIcon={<FontAwesomeIcon icon={faFingerprint} className="pr-2" />}
onClick={() => {
if (subscription && subscription.dynamicSecret) {
handlePopUpOpen("addDynamicSecret");
handlePopUpClose("misc");
return;
}
handlePopUpOpen("upgradePlan");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add Dynamic Secret
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFileImport} className="pr-2" />}
onClick={() => {
handlePopUpOpen("addSecretImport");
handlePopUpClose("misc");
}}
variant="outline_bg"
className="h-10"
className="h-10 text-left"
isFullWidth
isDisabled={!isAllowed}
>
@@ -388,7 +416,7 @@ export const ActionBar = ({
{Object.keys(selectedSecrets).length} Selected
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
@@ -418,6 +446,13 @@ export const ActionBar = ({
onClose={() => handlePopUpClose("addSecretImport")}
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
/>
<CreateDynamicSecretForm
isOpen={popUp.addDynamicSecret.isOpen}
onToggle={(isOpen) => handlePopUpToggle("addDynamicSecret", isOpen)}
projectSlug={projectSlug}
environment={environment}
secretPath={secretPath}
/>
<Modal
isOpen={popUp.addFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
@@ -439,8 +474,8 @@ export const ActionBar = ({
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={
subscription.slug === null
? "You can perform point-in-time recovery under an Enterprise license"
: "You can perform point-in-time recovery if you switch to Infisical's Team plan"
? "You can perform this action under an Enterprise license"
: "You can perform this action if you switch to Infisical's Team plan"
}
/>
)}
@@ -0,0 +1,105 @@
import { useState } from "react";
import { faDatabase } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm";
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
projectSlug: string;
environment: string;
secretPath: string;
};
enum WizardSteps {
SelectProvider = "select-provider",
ProviderInputs = "provider-inputs"
}
export const CreateDynamicSecretForm = ({
isOpen,
onToggle,
projectSlug,
environment,
secretPath
}: Props) => {
const [wizardStep, setWizardStep] = useState(WizardSteps.SelectProvider);
const [selectedProvider, setSelectedProvider] = useState<DynamicSecretProviders | null>(null);
const handleFormReset = (state: boolean = false) => {
onToggle(state);
setWizardStep(WizardSteps.SelectProvider);
setSelectedProvider(null);
};
return (
<Modal isOpen={isOpen} onOpenChange={(state) => handleFormReset(state)}>
<ModalContent
title="Dynamic secret setup"
subTitle="Configure dynamic secret parameters"
className="my-4 max-w-3xl"
>
<AnimatePresence exitBeforeEnter>
{wizardStep === WizardSteps.SelectProvider && (
<motion.div
key="select-type-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<div className="mb-4 text-mineshaft-300">Select a service to connect to:</div>
<div className="flex 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);
setWizardStep(WizardSteps.ProviderInputs);
}
}}
>
<FontAwesomeIcon icon={faDatabase} size="lg" />
<div className="text-center text-sm">
SQL
<br />
Database
</div>
</div>
</div>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.SqlDatabase && (
<motion.div
key="select-input-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<SqlDatabaseInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,397 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
SecretInput,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
provider: z.object({
client: z.nativeEnum(SqlProviders),
host: z.string().toLowerCase().min(1),
port: z.coerce.number(),
database: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
creationStatement: z.string().min(1),
revocationStatement: z.string().min(1),
renewStatement: z.string().optional(),
ca: z.string().optional()
}),
defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environment: string;
};
const getSqlStatements = (provider: SqlProviders) => {
if (provider === SqlProviders.MySql) {
return {
creationStatement:
"CREATE USER \"{{username}}\"@'%' IDENTIFIED BY '{{password}}';\nGRANT ALL ON \"{{database}}\".* TO \"{{username}}\"@'%';",
renewStatement: "",
revocationStatement:
'REVOKE ALL PRIVILEGES ON "{{database}}".* FROM "{{username}}"@\'%\';\nDROP USER "{{username}}"@\'%\';'
};
}
if (provider === SqlProviders.Oracle) {
return {
creationStatement:
'CREATE USER "{{username}}" IDENTIFIED BY "{{password}}";\nGRANT CONNECT TO "{{username}}";\nGRANT CREATE SESSION TO "{{username}}";',
renewStatement: "",
revocationStatement:
'REVOKE CONNECT FROM "{{username}}";\nREVOKE CREATE SESSION FROM "{{username}}";\nDROP USER "{{username}}";'
};
}
return {
creationStatement:
"CREATE USER \"{{username}}\" WITH ENCRYPTED PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';\nGRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"{{username}}\";",
renewStatement: "ALTER ROLE \"{{username}}\" VALID UNTIL '{{expiration}}';",
revocationStatement:
'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "{{username}}";\nDROP ROLE "{{username}}";'
};
};
export const SqlDatabaseInputForm = ({
onCompleted,
onCancel,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
setValue,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: getSqlStatements(SqlProviders.Postgres)
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isLoading) return;
try {
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.SqlDatabase, 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)}>
<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="pl-1 pb-0.5 text-sm text-mineshaft-400">Service</div>
<Controller
control={control}
name="provider.client"
defaultValue={SqlProviders.Postgres}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Select
value={value}
onValueChange={(val) => {
onChange(val);
const sqlStatment = getSqlStatements(val as SqlProviders);
setValue("provider.creationStatement", sqlStatment.creationStatement);
setValue("provider.renewStatement", sqlStatment.renewStatement);
setValue("provider.revocationStatement", sqlStatment.revocationStatement);
}}
className="w-full border border-mineshaft-500"
>
<SelectItem value={SqlProviders.Postgres}>PostgreSQL</SelectItem>
<SelectItem value={SqlProviders.MySql}>MySQL</SelectItem>
<SelectItem value={SqlProviders.Oracle}>Oracle</SelectItem>
</Select>
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={5432}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</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" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.database"
defaultValue="default"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Database Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify SQL Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.renewStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1 @@
export { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
@@ -3,7 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosError } from "axios";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
@@ -55,7 +55,7 @@ export const CreateSecretImportForm = ({
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
const { createNotification } = useNotificationContext();
const { mutateAsync: createSecretImport } = useCreateSecretImport();
@@ -2,7 +2,7 @@ import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput/InfisicalSecretInput";
import { useCreateSecretV3 } from "@app/hooks/api";
@@ -45,8 +45,6 @@ export const CreateSecretForm = ({
const { isOpen } = usePopUpState(PopUpNames.CreateSecretForm);
const { closePopUp, togglePopUp } = usePopUpAction();
const { createNotification } = useNotificationContext();
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const handleFormSubmit = async ({ key, value }: TFormSchema) => {
@@ -0,0 +1,185 @@
import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, IconButton, Input, SecretInput, Tooltip } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { useCreateDynamicSecretLease } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const OutputDisplay = ({
value,
label,
helperText
}: {
value: string;
label: string;
helperText?: string;
}) => {
const [copyText, isCopying, setCopyText] = useTimedReset<string>({
initialState: "Copy to clipboard"
});
return (
<div className="relative">
<FormControl label={label} className="flex-grow" helperText={helperText}>
<SecretInput
isReadOnly
value={value}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
<Tooltip content={copyText}>
<IconButton
ariaLabel="Copy to clipboard"
variant="plain"
size="md"
className="absolute right-2 top-7"
onClick={() => {
navigator.clipboard.writeText(value as string);
setCopyText("Copied");
}}
>
<FontAwesomeIcon icon={isCopying ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
);
};
const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string };
if (provider === DynamicSecretProviders.SqlDatabase) {
return (
<div>
<OutputDisplay label="Database User" value={DB_USERNAME} />
<OutputDisplay
label="Database 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;
};
const formSchema = z.object({
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecretName: string;
provider: DynamicSecretProviders;
projectSlug: string;
environment: string;
secretPath: string;
};
export const CreateDynamicSecretLease = ({
onClose,
projectSlug,
dynamicSecretName,
provider,
secretPath,
environment
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
ttl: "1h"
}
});
const createDynamicSecretLease = useCreateDynamicSecretLease();
const handleDynamicSecretLeaseCreate = async ({ ttl }: TForm) => {
if (createDynamicSecretLease.isLoading) return;
try {
await createDynamicSecretLease.mutateAsync({
environmentSlug: environment,
projectSlug,
path: secretPath,
ttl,
dynamicSecretName
});
createNotification({
type: "success",
text: "Successfully leased dynamic secret"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to lease dynamic secret"
});
}
};
const isOutputMode = Boolean(createDynamicSecretLease?.data);
return (
<div>
<AnimatePresence>
{!isOutputMode && (
<motion.div
key="lease-input"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<form onSubmit={handleSubmit(handleDynamicSecretLeaseCreate)}>
<Controller
control={control}
name="ttl"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<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>
</motion.div>
)}
{isOutputMode && (
<motion.div
key="lease-output"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
{renderOutputForm(provider, createDynamicSecretLease.data?.data)}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
@@ -0,0 +1,244 @@
import { subject } from "@casl/ability";
import {
faClose,
faFileContract,
faRepeat,
faTrash,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistance } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
EmptyState,
IconButton,
Modal,
ModalContent,
Table,
TableContainer,
TBody,
Td,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api";
import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types";
import { RenewDynamicSecretLease } from "./RenewDynamicSecretLease";
type Props = {
dynamicSecretName: string;
projectSlug: string;
environment: string;
secretPath: string;
onClickNewLease: () => void;
onClose: () => void;
};
export const DynamicSecretLease = ({
projectSlug,
dynamicSecretName,
environment,
secretPath,
onClickNewLease,
onClose
}: Props) => {
const { handlePopUpOpen, popUp, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteSecret",
"renewSecret"
] as const);
const { data: leases, isLoading: isLeaseLoading } = useGetDynamicSecretLeases({
projectSlug,
environmentSlug: environment,
path: secretPath,
dynamicSecretName
});
const deleteDynamicSecretLease = useRevokeDynamicSecretLease();
const handleDynamicSecretDeleteLease = async () => {
try {
const { leaseId, isForced } = popUp.deleteSecret.data as {
leaseId: string;
isForced?: boolean;
};
await deleteDynamicSecretLease.mutateAsync({
environmentSlug: environment,
projectSlug,
path: secretPath,
dynamicSecretName,
leaseId,
isForced
});
handlePopUpClose("deleteSecret");
createNotification({
type: "success",
text: "Successfully deleted lease"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to delete lease"
});
}
};
return (
<div>
<TableContainer>
<Table className="bg-mineshaft-600">
<THead>
<Tr>
<Td>Lease ID</Td>
<Td>Expire At</Td>
<Td />
</Tr>
</THead>
<TBody>
{!isLeaseLoading && leases?.length === 0 && (
<tr>
<td colSpan={3}>
<EmptyState title="No leases found" icon={faFileContract}>
<Button
onClick={onClickNewLease}
className="mt-4"
colorSchema="primary"
size="sm"
>
New Lease
</Button>
</EmptyState>
</td>
</tr>
)}
{(leases || []).map(({ id, expireAt, status, statusDetails }) => (
<Tr key={id}>
<Td>
{id}
{Boolean(status) && (
<Tooltip content={statusDetails || status || ""}>
<FontAwesomeIcon className="ml-2 text-yellow-600" icon={faWarning} />
</Tooltip>
)}
</Td>
<Td>
<Tooltip content={format(new Date(expireAt), "yyyy-MM-dd, hh:mm aaa")}>
<span className="capitalize">
{formatDistance(new Date(expireAt), new Date())}
</span>
</Tooltip>
</Td>
<Td>
<div className="flex items-center space-x-4">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Renew"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="p-0"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("renewSecret", { leaseId: id })}
>
<FontAwesomeIcon icon={faRepeat} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-folder"
variant="plain"
size="md"
className="p-0"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("deleteSecret", { leaseId: id })}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
{status === DynamicSecretLeaseStatus.FailedDeletion && (
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Force Delete. This action will remove the secret from internal storage, but it will remain in external systems."
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-folder"
variant="plain"
size="md"
className="p-0 text-red-600"
isDisabled={!isAllowed}
onClick={() =>
handlePopUpOpen("deleteSecret", { leaseId: id, isForced: true })
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
)}
</div>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
{!isLeaseLoading && Boolean(leases?.length) && (
<div className="mt-6 flex items-center space-x-4">
<Button onClick={onClickNewLease} size="xs">
New Lease
</Button>
<Button onClick={onClose} variant="plain" colorSchema="secondary" size="xs">
Close
</Button>
</div>
)}
<Modal
isOpen={popUp.renewSecret.isOpen}
onOpenChange={(state) => handlePopUpToggle("renewSecret", state)}
>
<ModalContent title="Renew Lease">
<RenewDynamicSecretLease
onClose={() => handlePopUpClose("renewSecret")}
projectSlug={projectSlug}
leaseId={(popUp.renewSecret?.data as { leaseId: string })?.leaseId}
dynamicSecretName={dynamicSecretName}
secretPath={secretPath}
environment={environment}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deleteSecret.isOpen}
deleteKey="delete"
title="Do you want to delete this lease?"
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
onDeleteApproved={handleDynamicSecretDeleteLease}
/>
</div>
);
};
@@ -0,0 +1,289 @@
import { subject } from "@casl/ability";
import {
faClose,
faFingerprint,
faPencilSquare,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
IconButton,
Modal,
ModalContent,
Tag,
Tooltip
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteDynamicSecret } from "@app/hooks/api";
import {
DynamicSecretProviders,
DynamicSecretStatus,
TDynamicSecret
} from "@app/hooks/api/dynamicSecret/types";
import { SortDir } from "../../SecretMainPage.types";
import { CreateDynamicSecretLease } from "./CreateDynamicSecretLease";
import { DynamicSecretLease } from "./DynamicSecretLease";
import { EditDynamicSecretForm } from "./EditDynamicSecretForm";
const formatProviderName = (type: DynamicSecretProviders) => {
if (type === DynamicSecretProviders.SqlDatabase) return "SQL Database";
return "";
};
type Props = {
dynamicSecrets: TDynamicSecret[];
environment: string;
projectSlug: string;
secretPath?: string;
sortDir: SortDir;
};
export const DynamicSecretListView = ({
dynamicSecrets = [],
environment,
projectSlug,
secretPath = "/",
sortDir = SortDir.ASC
}: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"dynamicSecretLeases",
"createDynamicSecretLease",
"updateDynamicSecret",
"deleteDynamicSecret"
] as const);
const deleteDynamicSecret = useDeleteDynamicSecret();
const handleDynamicSecretDelete = async () => {
try {
const { name, isForced } = popUp.deleteDynamicSecret.data as TDynamicSecret & {
isForced?: boolean;
};
await deleteDynamicSecret.mutateAsync({
environmentSlug: environment,
projectSlug,
path: secretPath,
name,
isForced
});
handlePopUpClose("deleteDynamicSecret");
createNotification({
type: "success",
text: "Successfully deleted dynamic secret"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to delete dynamic secret"
});
}
};
return (
<>
{dynamicSecrets
.sort((a, b) =>
sortDir === SortDir.ASC
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
)
.map((secret) => {
const isRevocking = secret.status === DynamicSecretStatus.Deleting;
return (
<Modal
key={secret.id}
isOpen={
popUp.dynamicSecretLeases.isOpen && popUp.dynamicSecretLeases.data === secret.id
}
onOpenChange={(state) => handlePopUpToggle("dynamicSecretLeases", state)}
>
<div
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter" && !isRevocking)
handlePopUpOpen("dynamicSecretLeases", secret.id);
}}
onClick={() => {
if (!isRevocking) {
handlePopUpOpen("dynamicSecretLeases", secret.id);
}
}}
>
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
<FontAwesomeIcon icon={faFingerprint} />
</div>
<div
className="flex flex-grow items-center px-4 py-3"
role="button"
tabIndex={0}
>
{secret.name}
<Tag className="ml-4 py-0 px-2 text-xs normal-case">
{formatProviderName(secret.type)}
</Tag>
{Boolean(secret.status) && (
<Tooltip content={secret?.statusDetails || secret.status || ""}>
<FontAwesomeIcon
className={
secret.status === DynamicSecretStatus.Deleting
? "text-yellow-600"
: "text-red-600"
}
icon={faWarning}
/>
</Tooltip>
)}
</div>
<div className="flex items-center space-x-2 px-4 py-2">
<Button
size="xs"
className="m-0 py-0.5 px-2 opacity-0 group-hover:opacity-100"
isDisabled={isRevocking}
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("createDynamicSecretLease", secret);
}}
>
Generate
</Button>
{secret.status === DynamicSecretStatus.FailedDeletion && (
<Tooltip content="This action will remove the secret from internal storage, but it will remain in external systems. Use this option only after you've confirmed that your external leases are handled.">
<Button
size="xs"
className="m-0 py-0.5 px-2"
colorSchema="danger"
isDisabled={isRevocking}
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("deleteDynamicSecret", {
...secret,
isForced: true
});
}}
>
Force Delete
</Button>
</Tooltip>
)}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-dynamic-secret"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("updateDynamicSecret", secret);
}}
isDisabled={!isAllowed || isRevocking}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-dynamic-secret"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("deleteDynamicSecret", secret);
}}
isDisabled={!isAllowed || isRevocking}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div>
<ModalContent
title="Dynamic secret leases"
subTitle="Revoke or renew your secret leases"
className="max-w-3xl"
>
<DynamicSecretLease
onClickNewLease={() => handlePopUpOpen("createDynamicSecretLease", secret)}
onClose={() => handlePopUpClose("dynamicSecretLeases")}
projectSlug={projectSlug}
key={secret.id}
dynamicSecretName={secret.name}
secretPath={secretPath}
environment={environment}
/>
</ModalContent>
</Modal>
);
})}
<Modal
isOpen={popUp.createDynamicSecretLease.isOpen}
onOpenChange={(state) => handlePopUpToggle("createDynamicSecretLease", state)}
>
<ModalContent title="Provision lease">
<CreateDynamicSecretLease
provider={
(popUp.createDynamicSecretLease?.data as { type: DynamicSecretProviders })?.type
}
onClose={() => handlePopUpClose("createDynamicSecretLease")}
projectSlug={projectSlug}
dynamicSecretName={(popUp.createDynamicSecretLease?.data as { name: string })?.name}
secretPath={secretPath}
environment={environment}
/>
</ModalContent>
</Modal>
<Modal
isOpen={popUp.updateDynamicSecret.isOpen}
onOpenChange={(state) => handlePopUpToggle("updateDynamicSecret", state)}
>
<ModalContent title="Edit dynamic secret" className="max-w-3xl">
<EditDynamicSecretForm
onClose={() => handlePopUpClose("updateDynamicSecret")}
projectSlug={projectSlug}
dynamicSecretName={(popUp.updateDynamicSecret?.data as TDynamicSecret)?.name}
secretPath={secretPath}
environment={environment}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deleteDynamicSecret.isOpen}
deleteKey={(popUp.deleteDynamicSecret?.data as TDynamicSecret)?.name}
title={
(popUp.deleteDynamicSecret?.data as { isForced?: boolean })?.isForced
? "Do you want to force delete this dynamic secret?"
: "Do you want to delete this dynamic secret?"
}
onChange={(isOpen) => handlePopUpToggle("deleteDynamicSecret", isOpen)}
onDeleteApproved={handleDynamicSecretDelete}
/>
</>
);
};
@@ -0,0 +1,61 @@
import { AnimatePresence, motion } from "framer-motion";
import { Spinner } from "@app/components/v2";
import { useGetDynamicSecretDetails } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { EditDynamicSecretSqlProviderForm } from "./EditDynamicSecretSqlProviderForm";
type Props = {
onClose: () => void;
dynamicSecretName: string;
projectSlug: string;
environment: string;
secretPath: string;
};
export const EditDynamicSecretForm = ({
dynamicSecretName,
environment,
projectSlug,
onClose,
secretPath
}: Props) => {
const { data: dynamicSecretDetails, isLoading: isDynamicSecretLoading } =
useGetDynamicSecretDetails({
projectSlug,
environmentSlug: environment,
name: dynamicSecretName,
path: secretPath
});
if (isDynamicSecretLoading) {
return (
<div className="flex w-full items-center justify-center p-8">
<Spinner />
</div>
);
}
return (
<AnimatePresence exitBeforeEnter>
{dynamicSecretDetails?.type === DynamicSecretProviders.SqlDatabase && (
<motion.div
key="sqldatabase-provider-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretSqlProviderForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,382 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
SecretInput,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useUpdateDynamicSecret } from "@app/hooks/api";
import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
inputs: z
.object({
client: z.nativeEnum(SqlProviders),
host: z.string().toLowerCase().min(1),
port: z.number(),
database: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
creationStatement: z.string().min(1),
revocationStatement: z.string().min(1),
renewStatement: z.string().optional(),
ca: z.string().optional()
})
.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 EditDynamicSecretSqlProviderForm = ({
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)}>
<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">
<Controller
control={control}
name="inputs.client"
defaultValue={SqlProviders.Postgres}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Select
isDisabled
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
>
<SelectItem value={SqlProviders.Postgres}>PostgreSQL</SelectItem>
<SelectItem value={SqlProviders.MySql}>MySQL</SelectItem>
<SelectItem value={SqlProviders.Oracle}>Oracle</SelectItem>
</Select>
</FormControl>
)}
/>
<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={5432}
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>
<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} />
</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" />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.database"
defaultValue="default"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Database Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="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 SQL Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="inputs.creationStatement"
defaultValue={
"CREATE USER \"{{username}}\" WITH SUPERUSER ENCRYPTED PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';\nGRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \"{{username}}\";"
}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.revocationStatement"
defaultValue={
'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "{{username}}";\nDROP OWNED BY "{{username}}"; DROP ROLE "{{username}}";'
}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.renewStatement"
defaultValue={"ALTER ROLE \"{{username}}\" VALID UNTIL '{{expiration}}';"}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Save
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1 @@
export { EditDynamicSecretForm } from "./EditDynamicSecretForm";
@@ -0,0 +1,107 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useRenewDynamicSecretLease } from "@app/hooks/api";
const formSchema = z.object({
ttl: z.string().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" });
})
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
leaseId: string;
dynamicSecretName: string;
projectSlug: string;
environment: string;
secretPath: string;
};
export const RenewDynamicSecretLease = ({
onClose,
projectSlug,
dynamicSecretName,
leaseId,
secretPath,
environment
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
ttl: "1h"
}
});
const renewDynamicSecretLease = useRenewDynamicSecretLease();
const handleDynamicSecretLeaseCreate = async ({ ttl }: TForm) => {
if (renewDynamicSecretLease.isLoading) return;
try {
await renewDynamicSecretLease.mutateAsync({
environmentSlug: environment,
projectSlug,
path: secretPath,
ttl,
dynamicSecretName,
leaseId
});
onClose();
createNotification({
type: "success",
text: "Successfully renewed lease"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to renew lease"
});
}
};
return (
<form onSubmit={handleSubmit(handleDynamicSecretLeaseCreate)}>
<Controller
control={control}
name="ttl"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="The existing expiration time will be extended by the TTL"
>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Renew
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1 @@
export { DynamicSecretListView } from "./DynamicSecretListView";
@@ -3,7 +3,7 @@ import { subject } from "@casl/ability";
import { faClose, faFolder, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
@@ -20,12 +20,14 @@ type Props = {
workspaceId: string;
secretPath?: string;
sortDir: SortDir;
searchTerm?: string;
};
export const FolderListView = ({
folders = [],
environment,
workspaceId,
searchTerm,
secretPath = "/",
sortDir = SortDir.ASC
}: Props) => {
@@ -35,8 +37,6 @@ export const FolderListView = ({
] as const);
const router = useRouter();
const { createNotification } = useNotificationContext();
const { mutateAsync: updateFolder } = useUpdateFolder();
const { mutateAsync: deleteFolder } = useDeleteFolder();
@@ -100,6 +100,7 @@ export const FolderListView = ({
return (
<>
{folders
.filter(({ name }) => name.toUpperCase().includes(String(searchTerm?.toUpperCase())))
.sort((a, b) =>
sortDir === SortDir.ASC
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
@@ -6,7 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality
import { parseDotEnv } from "@app/components/utilities/parseDotEnv";
@@ -60,7 +60,7 @@ export const SecretDropzone = ({
const { t } = useTranslation();
const [isDragActive, setDragActive] = useToggle();
const [isLoading, setIsLoading] = useToggle();
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"importSecEnv",
"overlapKeyWarning"
@@ -119,8 +119,7 @@ export const SecretDropzone = ({
if (!file) {
createNotification({
text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.",
type: "error",
timeoutMs: 10000
type: "error"
});
return;
}
@@ -12,7 +12,7 @@ import {
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api";
@@ -90,7 +90,7 @@ export const SecretImportListView = ({
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteSecretImport"
] as const);
const { createNotification } = useNotificationContext();
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
@@ -13,7 +13,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
@@ -71,7 +71,7 @@ export const SecretDetailSidebar = ({
environment,
secretPath
}: Props) => {
const { createNotification } = useNotificationContext();
const {
register,
control,
@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { createNotification } from "@app/components/notifications";
import { CreateTagModal } from "@app/components/tags/CreateTagModal";
import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
@@ -89,7 +89,7 @@ export const SecretListView = ({
isVisible,
isProtectedBranch = false
}: Props) => {
const { createNotification } = useNotificationContext();
const queryClient = useQueryClient();
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"deleteSecret",
@@ -9,7 +9,7 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, ContentLoader, Input, Tag, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
@@ -52,7 +52,7 @@ export const SnapshotView = ({
snapshotCount,
onClickListSnapshot
}: Props) => {
const { createNotification } = useNotificationContext();
const [search, setSearch] = useState("");
const { mutateAsync: performRollback, isLoading: isRollingBack } = usePerformSecretRollback();