feat(ui): completed ui for dynamic secret

This commit is contained in:
Akhil Mohan
2024-03-21 20:20:59 +05:30
parent 177cd385cc
commit e201e80a06
16 changed files with 1713 additions and 18 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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}

View File

@@ -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)}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { EditDynamicSecretForm } from "./EditDynamicSecretForm";

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { DynamicSecretListView } from "./DynamicSecretListView";