Merge branch 'main' into feat/addProjectDeletionProtection

This commit is contained in:
carlosmonastyrski
2025-04-16 10:00:23 -03:00
54 changed files with 1707 additions and 271 deletions
@@ -100,7 +100,8 @@ export enum PermissionConditionOperators {
$REGEX = "$regex",
$EQ = "$eq",
$NEQ = "$ne",
$GLOB = "$glob"
$GLOB = "$glob",
$ELEMENTMATCH = "$elemMatch"
}
export type IdentityManagementSubjectFields = {
@@ -113,7 +114,8 @@ export const formatedConditionsOperatorNames: { [K in PermissionConditionOperato
[PermissionConditionOperators.$ALL]: "contains all",
[PermissionConditionOperators.$NEQ]: "not equal to",
[PermissionConditionOperators.$GLOB]: "matches glob pattern",
[PermissionConditionOperators.$REGEX]: "matches regex pattern"
[PermissionConditionOperators.$REGEX]: "matches regex pattern",
[PermissionConditionOperators.$ELEMENTMATCH]: "element matches"
};
export type TPermissionConditionOperators = {
@@ -123,12 +125,24 @@ export type TPermissionConditionOperators = {
[PermissionConditionOperators.$NEQ]: string;
[PermissionConditionOperators.$REGEX]: string;
[PermissionConditionOperators.$GLOB]: string;
[PermissionConditionOperators.$ELEMENTMATCH]: Record<
string,
Partial<TPermissionConditionOperators>
>;
};
export type TPermissionCondition = Record<
string,
| string
| { $in: string[]; $all: string[]; $regex: string; $eq: string; $ne: string; $glob: string }
| {
$in: string[];
$all: string[];
$regex: string;
$eq: string;
$ne: string;
$glob: string;
$elemMatch: Partial<TPermissionCondition>;
}
>;
export enum ProjectPermissionSub {
@@ -182,6 +196,7 @@ export type SecretFolderSubjectFields = {
export type DynamicSecretSubjectFields = {
environment: string;
secretPath: string;
metadata?: (string | { key: string; value: string })[];
};
export type SecretImportSubjectFields = {
@@ -13,6 +13,7 @@ export type TDynamicSecret = {
status?: DynamicSecretStatus;
statusDetails?: string;
maxTTL: string;
metadata?: { key: string; value: string }[];
};
export enum DynamicSecretProviders {
@@ -261,6 +262,7 @@ export type TDynamicSecretProvider =
digits?: number;
};
};
export type TCreateDynamicSecretDTO = {
projectSlug: string;
provider: TDynamicSecretProvider;
@@ -269,6 +271,7 @@ export type TCreateDynamicSecretDTO = {
path: string;
environmentSlug: string;
name: string;
metadata?: { key: string; value: string }[];
};
export type TUpdateDynamicSecretDTO = {
@@ -278,6 +281,7 @@ export type TUpdateDynamicSecretDTO = {
environmentSlug: string;
data: {
newName?: string;
metadata?: { key: string; value: string }[];
defaultTTL?: string;
maxTTL?: string | null;
inputs?: unknown;
@@ -0,0 +1,189 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import {
PermissionConditionOperators,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import {
getConditionOperatorHelperInfo,
renderOperatorSelectItems
} from "./PermissionConditionHelpers";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
position?: number;
isDisabled?: boolean;
};
export const DynamicSecretPermissionConditions = ({ position = 0, isDisabled }: Props) => {
const {
control,
watch,
setValue,
formState: { errors }
} = useFormContext<TFormSchema>();
const items = useFieldArray({
control,
name: `permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions`
});
const conditionErrorMessage =
errors?.permissions?.[ProjectPermissionSub.DynamicSecrets]?.[position]?.conditions?.message ||
errors?.permissions?.[ProjectPermissionSub.DynamicSecrets]?.[position]?.conditions?.root
?.message;
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="text-sm text-mineshaft-400">
Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
const condition = watch(
`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}`
) as {
lhs: string;
rhs: string;
operator: string;
};
return (
<div
key={el.id}
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
>
<div className="w-1/4">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.lhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
setValue(
`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.operator`,
PermissionConditionOperators.$IN as never
);
field.onChange(e);
}}
className="w-full"
>
<SelectItem value="environment">Environment Slug</SelectItem>
<SelectItem value="secretPath">Secret Path</SelectItem>
<SelectItem value="metadataKey">Metadata Key</SelectItem>
<SelectItem value="metadataValue">Metadata Value</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex w-36 items-center space-x-2">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.operator`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
{renderOperatorSelectItems(condition.lhs)}
</Select>
</FormControl>
)}
/>
<div>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
</div>
<div className="flex-grow">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.rhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<IconButton
ariaLabel="plus"
variant="outline_bg"
className="p-2.5"
onClick={() => items.remove(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
})}
</div>
{conditionErrorMessage && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{conditionErrorMessage}</span>
</div>
)}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-3"
isDisabled={isDisabled}
onClick={() =>
items.append({
lhs: "environment",
operator: PermissionConditionOperators.$EQ,
rhs: ""
})
}
>
Add Condition
</Button>
</div>
</div>
);
};
@@ -285,11 +285,59 @@ const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition
} else {
Object.keys(condition).forEach((conditionOperator) => {
const rhs = condition[conditionOperator as PermissionConditionOperators];
formConditions.push({
operator: conditionOperator,
lhs: type,
rhs: typeof rhs === "string" ? rhs : rhs.join(",")
});
if (Array.isArray(rhs) || typeof rhs === "string") {
formConditions.push({
operator: conditionOperator,
lhs: type,
rhs: typeof rhs === "string" ? rhs : rhs.join(",")
});
} else if (
conditionOperator === PermissionConditionOperators.$ELEMENTMATCH &&
type === "metadata"
) {
const deepKeyCondition = rhs.key;
if (deepKeyCondition) {
if (typeof deepKeyCondition === "string") {
formConditions.push({
operator: PermissionConditionOperators.$EQ,
lhs: "metadataKey",
rhs: deepKeyCondition
});
} else {
Object.keys(deepKeyCondition).forEach((keyOperator) => {
const deepRhs = deepKeyCondition?.[keyOperator as PermissionConditionOperators];
if (deepRhs && (Array.isArray(deepRhs) || typeof deepRhs === "string")) {
formConditions.push({
operator: keyOperator,
lhs: "metadataKey",
rhs: typeof deepRhs === "string" ? deepRhs : deepRhs.join(",")
});
}
});
}
}
const deepValueCondition = rhs.value;
if (deepValueCondition) {
if (typeof deepValueCondition === "string") {
formConditions.push({
operator: PermissionConditionOperators.$EQ,
lhs: "metadataValue",
rhs: deepValueCondition
});
} else {
Object.keys(deepValueCondition).forEach((keyOperator) => {
const deepRhs = deepValueCondition?.[keyOperator as PermissionConditionOperators];
if (deepRhs && (Array.isArray(deepRhs) || typeof deepRhs === "string")) {
formConditions.push({
operator: keyOperator,
lhs: "metadataValue",
rhs: typeof deepRhs === "string" ? deepRhs : deepRhs.join(",")
});
}
});
}
}
}
});
}
});
@@ -636,7 +684,45 @@ const convertFormOperatorToCaslCondition = (
conditions: { lhs: string; rhs: string; operator: string }[]
) => {
const caslCondition: Record<string, Partial<TPermissionConditionOperators>> = {};
const metadataKeyCondition = conditions.find((condition) => condition.lhs === "metadataKey");
const metadataValueCondition = conditions.find((condition) => condition.lhs === "metadataValue");
if (metadataKeyCondition || metadataValueCondition) {
caslCondition.metadata = {
[PermissionConditionOperators.$ELEMENTMATCH]: {}
};
if (metadataKeyCondition) {
const operator = metadataKeyCondition.operator as PermissionConditionOperators;
caslCondition.metadata[PermissionConditionOperators.$ELEMENTMATCH]!.key = {
[metadataKeyCondition.operator]: [
PermissionConditionOperators.$IN,
PermissionConditionOperators.$ALL
].includes(operator)
? metadataKeyCondition.rhs.split(",")
: metadataKeyCondition.rhs
};
}
if (metadataValueCondition) {
const operator = metadataValueCondition.operator as PermissionConditionOperators;
caslCondition.metadata[PermissionConditionOperators.$ELEMENTMATCH]!.value = {
[metadataValueCondition.operator]: [
PermissionConditionOperators.$IN,
PermissionConditionOperators.$ALL
].includes(operator)
? metadataValueCondition.rhs.split(",")
: metadataValueCondition.rhs
};
}
}
conditions.forEach((el) => {
// these are special fields and handled above
if (el.lhs === "metadataKey" || el.lhs === "metadataValue") {
return;
}
if (!caslCondition[el.lhs]) caslCondition[el.lhs] = {};
if (
el.operator === PermissionConditionOperators.$IN ||
@@ -647,7 +733,9 @@ const convertFormOperatorToCaslCondition = (
caslCondition[el.lhs][
el.operator as Exclude<
PermissionConditionOperators,
PermissionConditionOperators.$ALL | PermissionConditionOperators.$IN
| PermissionConditionOperators.$ALL
| PermissionConditionOperators.$IN
| PermissionConditionOperators.$ELEMENTMATCH
>
] = el.rhs;
}
@@ -21,6 +21,7 @@ import { evaluatePermissionsAbility } from "@app/helpers/permissions";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { DynamicSecretPermissionConditions } from "./DynamicSecretPermissionConditions";
import { GeneralPermissionConditions } from "./GeneralPermissionConditions";
import { GeneralPermissionPolicies } from "./GeneralPermissionPolicies";
import { IdentityManagementPermissionConditions } from "./IdentityManagementPermissionConditions";
@@ -48,6 +49,9 @@ export const renderConditionalComponents = (
if (subject === ProjectPermissionSub.Secrets)
return <SecretPermissionConditions isDisabled={isDisabled} />;
if (subject === ProjectPermissionSub.DynamicSecrets)
return <DynamicSecretPermissionConditions isDisabled={isDisabled} />;
if (isConditionalSubjects(subject)) {
if (subject === ProjectPermissionSub.Identity) {
return <IdentityManagementPermissionConditions isDisabled={isDisabled} />;
@@ -207,7 +207,8 @@ export const OverviewPage = () => {
ProjectPermissionDynamicSecretActions.CreateRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: env.slug,
secretPath
secretPath,
metadata: ["*"]
})
)
);
@@ -138,7 +138,7 @@ const Page = () => {
const canReadDynamicSecret = permission.can(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath, metadata: ["*"] })
);
const canReadSecretRotations = permission.can(
@@ -532,7 +532,7 @@ const Page = () => {
importedBy={importedBy}
/>
)}
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />}
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
{!canReadSecret &&
!canReadDynamicSecret &&
!canReadSecretImports &&
@@ -864,7 +864,8 @@ export const ActionBar = ({
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
secretTags: ["*"],
metadata: ["*"]
})}
>
{(isAllowed) => (
@@ -25,6 +25,8 @@ import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { MetadataForm } from "../../DynamicSecretListView/MetadataForm";
const passwordRequirementsSchema = z
.object({
length: z.number().min(1).max(250),
@@ -82,8 +84,16 @@ const formSchema = z.object({
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
environment: z.object({ name: z.string(), slug: z.string() }),
metadata: z
.object({
key: z.string().trim().min(1),
value: z.string().trim().default("")
})
.array()
.optional()
});
type TForm = z.infer<typeof formSchema>;
type Props = {
@@ -192,7 +202,8 @@ export const SqlDatabaseInputForm = ({
maxTTL,
provider,
defaultTTL,
environment
environment,
metadata
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
@@ -205,7 +216,8 @@ export const SqlDatabaseInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment.slug
environmentSlug: environment.slug,
metadata
});
onCompleted();
} catch {
@@ -283,46 +295,47 @@ export const SqlDatabaseInputForm = ({
/>
</div>
</div>
<div>
<Controller
control={control}
name="provider.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Select
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.projectGatewayId}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<MetadataForm control={control} />
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div>
<Controller
control={control}
name="provider.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Select
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.projectGatewayId}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex flex-col">
<div className="pb-0.5 pl-1 text-sm text-mineshaft-400">Service</div>
<Controller
@@ -29,11 +29,13 @@ import {
import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types";
import { RenewDynamicSecretLease } from "./RenewDynamicSecretLease";
type Props = {
dynamicSecret: TDynamicSecret;
dynamicSecretName: string;
projectSlug: string;
environment: string;
@@ -48,7 +50,8 @@ export const DynamicSecretLease = ({
environment,
secretPath,
onClickNewLease,
onClose
onClose,
dynamicSecret
}: Props) => {
const { handlePopUpOpen, popUp, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteSecret",
@@ -140,7 +143,11 @@ export const DynamicSecretLease = ({
<div className="flex items-center space-x-4">
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip
allowedLabel="Renew"
>
@@ -159,7 +166,11 @@ export const DynamicSecretLease = ({
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip
allowedLabel="Delete"
>
@@ -181,7 +192,8 @@ export const DynamicSecretLease = ({
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip
allowedLabel="Force Delete. This action will remove the secret from internal storage, but it will remain in external systems."
@@ -215,7 +227,8 @@ export const DynamicSecretLease = ({
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath
secretPath,
metadata: dynamicSecret.metadata
})}
>
{(isAllowed) => (
@@ -144,7 +144,11 @@ export const DynamicSecretListView = ({
<div className="flex items-center space-x-2 px-4 py-2">
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: secret.metadata
})}
renderTooltip
allowedLabel="Edit"
>
@@ -186,7 +190,11 @@ export const DynamicSecretListView = ({
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.EditRootCredential}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: secret.metadata
})}
renderTooltip
allowedLabel="Edit"
>
@@ -208,7 +216,11 @@ export const DynamicSecretListView = ({
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.DeleteRootCredential}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: secret.metadata
})}
renderTooltip
allowedLabel="Delete"
>
@@ -236,6 +248,7 @@ export const DynamicSecretListView = ({
className="max-w-3xl"
>
<DynamicSecretLease
dynamicSecret={secret}
onClickNewLease={() => handlePopUpOpen("createDynamicSecretLease", secret)}
onClose={() => handlePopUpClose("dynamicSecretLeases")}
projectSlug={projectSlug}
@@ -23,6 +23,8 @@ import { useWorkspace } from "@app/context";
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { MetadataForm } from "../MetadataForm";
const passwordRequirementsSchema = z
.object({
length: z.number().min(1).max(250),
@@ -85,6 +87,13 @@ const formSchema = z.object({
newName: z
.string()
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.optional(),
metadata: z
.object({
key: z.string().trim().min(1),
value: z.string().trim().default("")
})
.array()
.optional()
});
type TForm = z.infer<typeof formSchema>;
@@ -126,6 +135,7 @@ export const EditDynamicSecretSqlProviderForm = ({
defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name,
metadata: dynamicSecret.metadata,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"]),
passwordRequirements:
@@ -147,7 +157,13 @@ export const EditDynamicSecretSqlProviderForm = ({
const isGatewayInActive =
projectGateways?.findIndex((el) => el.projectGatewayId === selectedProjectGatewayId) === -1;
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
const handleUpdateDynamicSecret = async ({
inputs,
maxTTL,
defaultTTL,
newName,
metadata
}: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isPending) return;
try {
@@ -163,7 +179,8 @@ export const EditDynamicSecretSqlProviderForm = ({
...inputs,
projectGatewayId: isGatewayInActive ? null : inputs.projectGatewayId
},
newName: newName === dynamicSecret.name ? undefined : newName
newName: newName === dynamicSecret.name ? undefined : newName,
metadata
}
});
onClose();
@@ -229,46 +246,50 @@ export const EditDynamicSecretSqlProviderForm = ({
/>
</div>
</div>
<div>
<Controller
control={control}
name="inputs.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message) || isGatewayInActive}
errorText={
isGatewayInActive && selectedProjectGatewayId
? `Project Gateway ${selectedProjectGatewayId} is removed`
: error?.message
}
label="Gateway"
helperText=""
>
<Select
value={value || undefined}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet Gateway"
position="popper"
>
<SelectItem value={null as unknown as string} onClick={() => onChange(undefined)}>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<MetadataForm control={control} />
<div>
<div className="mb-4 border-b border-b-mineshaft-600 pb-2">Configuration</div>
<div>
<Controller
control={control}
name="inputs.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message) || isGatewayInActive}
errorText={
isGatewayInActive && selectedProjectGatewayId
? `Project Gateway ${selectedProjectGatewayId} is removed`
: error?.message
}
label="Gateway"
helperText=""
>
<Select
value={value || undefined}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex flex-col">
<Controller
control={control}
@@ -0,0 +1,76 @@
import { Control, Controller, useFieldArray } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormControl, FormLabel, IconButton, Input } from "@app/components/v2";
export const MetadataForm = ({ control }: { control: Control<any> }) => {
const metadataFormFields = useFieldArray({
control,
name: "metadata"
});
return (
<FormControl label="Metadata">
<div className="flex flex-col space-y-2">
{metadataFormFields.fields.map(({ id: metadataFieldId }, i) => (
<div key={metadataFieldId} className="flex items-end space-x-2">
<div className="flex-grow">
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
<Controller
control={control}
name={`metadata.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} className="max-h-8" />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
{i === 0 && (
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
)}
<Controller
control={control}
name={`metadata.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} className="max-h-8" />
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 max-h-8"
variant="outline_bg"
onClick={() => metadataFormFields.remove(i)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div className={`${metadataFormFields.fields.length > 0 ? "pt-2" : ""}`}>
<IconButton
ariaLabel="Add Key"
variant="outline_bg"
size="xs"
className="rounded-md"
onClick={() => metadataFormFields.append({ key: "", value: "" })}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</div>
</div>
</FormControl>
);
};