mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(ui): completed ui for dynamic secret
This commit is contained in:
31
frontend/package-lock.json
generated
31
frontend/package-lock.json
generated
@@ -4,6 +4,7 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.5.0",
|
||||
"@casl/react": "^3.1.0",
|
||||
@@ -66,6 +67,7 @@
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lottie-react": "^2.4.0",
|
||||
"markdown-it": "^13.0.1",
|
||||
"ms": "^2.1.3",
|
||||
"next": "^12.3.4",
|
||||
"nprogress": "^0.2.0",
|
||||
"picomatch": "^2.3.1",
|
||||
@@ -11455,6 +11457,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/debug/node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
|
||||
@@ -13759,9 +13766,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
|
||||
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
|
||||
"version": "1.15.6",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
|
||||
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -15126,9 +15133,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz",
|
||||
"integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
|
||||
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
@@ -17518,9 +17525,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/multipipe": {
|
||||
"version": "1.0.2",
|
||||
@@ -21287,12 +21294,6 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lottie-react": "^2.4.0",
|
||||
"markdown-it": "^13.0.1",
|
||||
"ms": "^2.1.3",
|
||||
"next": "^12.3.4",
|
||||
"nprogress": "^0.2.0",
|
||||
"picomatch": "^2.3.1",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useGetDynamicSecrets,
|
||||
useGetImportedSecretsSingleEnv,
|
||||
useGetProjectFolders,
|
||||
useGetProjectSecrets,
|
||||
@@ -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";
|
||||
@@ -103,12 +105,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,
|
||||
@@ -133,6 +137,13 @@ export const SecretMainPage = () => {
|
||||
enabled: canReadSecret
|
||||
}
|
||||
});
|
||||
|
||||
const { data: dynamicSecrets, isLoading: isDynamicSecretLoading } = useGetDynamicSecrets({
|
||||
projectId: workspaceId,
|
||||
environment,
|
||||
path: secretPath
|
||||
});
|
||||
|
||||
// fech tags
|
||||
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
|
||||
|
||||
@@ -163,7 +174,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 +226,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) {
|
||||
@@ -298,6 +312,15 @@ export const SecretMainPage = () => {
|
||||
secretPath={secretPath}
|
||||
sortDir={sortDir}
|
||||
/>
|
||||
{canReadSecret && (
|
||||
<DynamicSecretListView
|
||||
sortDir={sortDir}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
dynamicSecrets={dynamicSecrets || []}
|
||||
/>
|
||||
)}
|
||||
{canReadSecret && (
|
||||
<SecretListView
|
||||
secrets={secrets}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
faEyeSlash,
|
||||
faFileImport,
|
||||
faFilter,
|
||||
faFingerprint,
|
||||
faFolderPlus,
|
||||
faMagnifyingGlass,
|
||||
faMinusSquare,
|
||||
@@ -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";
|
||||
|
||||
@@ -93,6 +95,7 @@ export const ActionBar = ({
|
||||
}: Props) => {
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
"addFolder",
|
||||
"addDynamicSecret",
|
||||
"addSecretImport",
|
||||
"bulkDeleteSecrets",
|
||||
"misc",
|
||||
@@ -311,7 +314,6 @@ export const ActionBar = ({
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
||||
<DropdownMenu
|
||||
open={popUp.misc.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
||||
@@ -347,6 +349,26 @@ export const ActionBar = ({
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFingerprint} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addDynamicSecret");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
isFullWidth
|
||||
>
|
||||
Add Dynamic Secret
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
@@ -418,6 +440,13 @@ export const ActionBar = ({
|
||||
onClose={() => handlePopUpClose("addSecretImport")}
|
||||
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
|
||||
/>
|
||||
<CreateDynamicSecretForm
|
||||
isOpen={popUp.addDynamicSecret.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("addDynamicSecret", isOpen)}
|
||||
workspaceId={workspaceId}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp.addFolder.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
|
||||
|
||||
@@ -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;
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
enum WizardSteps {
|
||||
SelectProvider = "select-provider",
|
||||
ProviderInputs = "provider-inputs"
|
||||
}
|
||||
|
||||
export const CreateDynamicSecretForm = ({
|
||||
isOpen,
|
||||
onToggle,
|
||||
workspaceId,
|
||||
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="Secrets provisioned dynamically with a TTL"
|
||||
className="my-4 max-h-screen max-w-3xl overflow-scroll"
|
||||
>
|
||||
<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-center">Pick a service to connect</div>
|
||||
<div className="flex items-center justify-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-700 p-6 transition-all hover:bg-bunker-600"
|
||||
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}
|
||||
projectId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import {
|
||||
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.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().min(1),
|
||||
ca: z.string().optional()
|
||||
}),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
// a day
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
// a day
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
slug: z.string().toLowerCase()
|
||||
});
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onCompleted: () => void;
|
||||
onCancel: () => void;
|
||||
secretPath: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
export const SqlDatabaseInputForm = ({
|
||||
onCompleted,
|
||||
onCancel,
|
||||
environment,
|
||||
secretPath,
|
||||
projectId
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
const { createNotification } = useNotificationContext();
|
||||
const createDynamicSecret = useCreateDynamicSecret();
|
||||
|
||||
const handleCreateDynamicSecret = async ({ slug, 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,
|
||||
slug,
|
||||
path: secretPath,
|
||||
defaultTTL,
|
||||
projectId,
|
||||
environment
|
||||
});
|
||||
onCompleted();
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(handleCreateDynamicSecret)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="slug"
|
||||
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"
|
||||
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 border-b border-b-mineshaft-600 pb-2">Configuration</div>
|
||||
<div className="flex flex-col">
|
||||
<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)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
<SelectItem value={SqlProviders.Postgres}>PostgreSQL</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>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.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="provider.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="provider.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>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||
@@ -0,0 +1,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 { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
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 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;
|
||||
slug: string;
|
||||
provider: DynamicSecretProviders;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
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 { username, password } = data as { username: string; password: string };
|
||||
if (provider === DynamicSecretProviders.SqlDatabase) {
|
||||
return (
|
||||
<div>
|
||||
<OutputDisplay label="Database User" value={username} />
|
||||
<OutputDisplay
|
||||
label="Database Password"
|
||||
value={password}
|
||||
helperText="Important: Copy this information now. It will disappear after this.opy this values as you won't be able to see it again."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const CreateDynamicSecretLease = ({
|
||||
onClose,
|
||||
projectId,
|
||||
slug,
|
||||
provider,
|
||||
secretPath,
|
||||
environment
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
ttl: "1h"
|
||||
}
|
||||
});
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const createDynamicSecretLease = useCreateDynamicSecretLease();
|
||||
|
||||
const handleDynamicSecretLeaseCreate = async ({ ttl }: TForm) => {
|
||||
if(createDynamicSecretLease.isLoading) return;
|
||||
try {
|
||||
await createDynamicSecretLease.mutateAsync({
|
||||
environment,
|
||||
projectId,
|
||||
path: secretPath,
|
||||
ttl,
|
||||
slug
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted folder"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete folder"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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,213 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import { faClose, faFileContract, faInfoCircle, faRepeat } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalContent,
|
||||
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 { RenewDynamicSecretLease } from "./RenewDynamicSecretLease";
|
||||
|
||||
type Props = {
|
||||
slug: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onClickNewLease: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DynamicSecretLease = ({
|
||||
projectId,
|
||||
slug,
|
||||
environment,
|
||||
secretPath,
|
||||
onClickNewLease,
|
||||
onClose
|
||||
}: Props) => {
|
||||
const { handlePopUpOpen, popUp, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"deleteSecret",
|
||||
"renewSecret"
|
||||
] as const);
|
||||
const { data: leases, isLoading: isLeaseLoading } = useGetDynamicSecretLeases({
|
||||
projectId,
|
||||
environment,
|
||||
path: secretPath,
|
||||
slug
|
||||
});
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const deleteDynamicSecretLease = useRevokeDynamicSecretLease();
|
||||
|
||||
const handleDynamicSecretDeleteLease = async () => {
|
||||
try {
|
||||
const { leaseId } = popUp.deleteSecret.data as { leaseId: string };
|
||||
await deleteDynamicSecretLease.mutateAsync({
|
||||
environment,
|
||||
projectId,
|
||||
path: secretPath,
|
||||
slug,
|
||||
leaseId
|
||||
});
|
||||
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="relative bottom-2 left-1 text-red-600"
|
||||
icon={faInfoCircle}
|
||||
/>
|
||||
</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>
|
||||
</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")}
|
||||
projectId={projectId}
|
||||
leaseId={(popUp.renewSecret?.data as { leaseId: string })?.leaseId}
|
||||
slug={slug}
|
||||
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,259 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faClose,
|
||||
faFingerprint,
|
||||
faInfoCircle,
|
||||
faPencilSquare
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
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;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
sortDir: SortDir;
|
||||
};
|
||||
|
||||
export const DynamicSecretListView = ({
|
||||
dynamicSecrets = [],
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath = "/",
|
||||
sortDir = SortDir.ASC
|
||||
}: Props) => {
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"dynamicSecretLeases",
|
||||
"createDynamicSecretLease",
|
||||
"updateDynamicSecret",
|
||||
"deleteDynamicSecret"
|
||||
] as const);
|
||||
|
||||
const { createNotification } = useNotificationContext();
|
||||
const deleteDynamicSecret = useDeleteDynamicSecret();
|
||||
|
||||
const handleDynamicSecretDelete = async () => {
|
||||
try {
|
||||
const { slug } = popUp.deleteDynamicSecret.data as TDynamicSecret;
|
||||
await deleteDynamicSecret.mutateAsync({
|
||||
environment,
|
||||
projectId: workspaceId,
|
||||
path: secretPath,
|
||||
slug
|
||||
});
|
||||
handlePopUpClose("deleteDynamicSecret");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted secret"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{dynamicSecrets
|
||||
.sort((a, b) =>
|
||||
sortDir === SortDir.ASC
|
||||
? a.slug.toLowerCase().localeCompare(b.slug.toLowerCase())
|
||||
: b.slug.toLowerCase().localeCompare(a.slug.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 uppercase"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{secret.slug}{" "}
|
||||
<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="relative bottom-2 text-red-600"
|
||||
icon={faInfoCircle}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center 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);
|
||||
}}
|
||||
>
|
||||
lease
|
||||
</Button>
|
||||
</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")}
|
||||
projectId={workspaceId}
|
||||
key={secret.id}
|
||||
slug={secret.slug}
|
||||
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")}
|
||||
projectId={workspaceId}
|
||||
slug={(popUp.createDynamicSecretLease?.data as { slug: string })?.slug}
|
||||
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")}
|
||||
projectId={workspaceId}
|
||||
slug={(popUp.updateDynamicSecret?.data as TDynamicSecret)?.slug}
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteDynamicSecret.isOpen}
|
||||
deleteKey={(popUp.deleteDynamicSecret?.data as TDynamicSecret)?.slug}
|
||||
title="Do you want to delete this dynamc 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;
|
||||
slug: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export const EditDynamicSecretForm = ({
|
||||
slug,
|
||||
environment,
|
||||
projectId,
|
||||
onClose,
|
||||
secretPath
|
||||
}: Props) => {
|
||||
const { data: dynamicSecretDetails, isLoading: isDynamicSecretLoading } =
|
||||
useGetDynamicSecretDetails({
|
||||
projectId,
|
||||
environment,
|
||||
slug,
|
||||
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}
|
||||
projectId={projectId}
|
||||
secretPath={secretPath}
|
||||
dynamicSecret={dynamicSecretDetails}
|
||||
environment={environment}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import {
|
||||
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().min(1),
|
||||
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(),
|
||||
newSlug: z.string().toLowerCase().optional()
|
||||
});
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
dynamicSecret: TDynamicSecret & { inputs: unknown };
|
||||
secretPath: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
export const EditDynamicSecretSqlProviderForm = ({
|
||||
onClose,
|
||||
dynamicSecret,
|
||||
environment,
|
||||
secretPath,
|
||||
projectId
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
defaultTTL: dynamicSecret.defaultTTL,
|
||||
maxTTL: dynamicSecret.maxTTL,
|
||||
newSlug: dynamicSecret.slug,
|
||||
inputs: {
|
||||
...(dynamicSecret.inputs as TForm["inputs"])
|
||||
}
|
||||
}
|
||||
});
|
||||
const { createNotification } = useNotificationContext();
|
||||
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||
|
||||
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newSlug }: TForm) => {
|
||||
// wait till previous request is finished
|
||||
if (updateDynamicSecret.isLoading) return;
|
||||
try {
|
||||
await updateDynamicSecret.mutateAsync({
|
||||
slug: dynamicSecret.slug,
|
||||
path: secretPath,
|
||||
projectId,
|
||||
environment,
|
||||
data: {
|
||||
maxTTL: maxTTL || undefined,
|
||||
defaultTTL,
|
||||
inputs,
|
||||
newSlug: newSlug === dynamicSecret.slug ? undefined : newSlug
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated dynamic secret"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="newSlug"
|
||||
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
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
<SelectItem value={SqlProviders.Postgres}>PostgreSQL</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" />
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</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 { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
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;
|
||||
slug: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export const RenewDynamicSecretLease = ({
|
||||
onClose,
|
||||
projectId,
|
||||
slug,
|
||||
leaseId,
|
||||
secretPath,
|
||||
environment
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
ttl: "1h"
|
||||
}
|
||||
});
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const renewDynamicSecretLease = useRenewDynamicSecretLease();
|
||||
|
||||
const handleDynamicSecretLeaseCreate = async ({ ttl }: TForm) => {
|
||||
if (renewDynamicSecretLease.isLoading) return;
|
||||
try {
|
||||
await renewDynamicSecretLease.mutateAsync({
|
||||
environment,
|
||||
projectId,
|
||||
path: secretPath,
|
||||
ttl,
|
||||
slug,
|
||||
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";
|
||||
Reference in New Issue
Block a user