Merge remote-tracking branch 'origin/main' into feat/addHerokuSecretSync

This commit is contained in:
carlosmonastyrski
2025-06-19 08:47:03 -03:00
205 changed files with 5151 additions and 759 deletions
@@ -7,6 +7,7 @@ import React, {
useMemo,
useState
} from "react";
import { FormProvider, useForm } from "react-hook-form";
import { ViewMode } from "../types";
@@ -23,8 +24,11 @@ interface AccessTreeProviderProps {
children: ReactNode;
}
export type AccessTreeForm = { metadata: { key: string; value: string }[] };
export const AccessTreeProvider: React.FC<AccessTreeProviderProps> = ({ children }) => {
const [secretName, setSecretName] = useState("");
const formMethods = useForm<AccessTreeForm>({ defaultValues: { metadata: [] } });
const [viewMode, setViewMode] = useState(ViewMode.Docked);
const value = useMemo(
@@ -37,7 +41,11 @@ export const AccessTreeProvider: React.FC<AccessTreeProviderProps> = ({ children
[secretName, setSecretName, viewMode, setViewMode]
);
return <AccessTreeContext.Provider value={value}>{children}</AccessTreeContext.Provider>;
return (
<FormProvider {...formMethods}>
<AccessTreeContext.Provider value={value}>{children}</AccessTreeContext.Provider>
</FormProvider>
);
};
export const useAccessTreeContext = (): AccessTreeContextProps => {
@@ -1,10 +1,12 @@
import { Dispatch, SetStateAction, useState } from "react";
import { useFormContext } from "react-hook-form";
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Panel } from "@xyflow/react";
import { Button, FormLabel, IconButton, Input, Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { MetadataForm } from "@app/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/MetadataForm";
import { ViewMode } from "../types";
@@ -32,6 +34,7 @@ export const PermissionSimulation = ({
setSecretName
}: TProps) => {
const [expand, setExpand] = useState(false);
const { control } = useFormContext();
const handlePermissionSimulation = () => {
setExpand(true);
@@ -139,6 +142,11 @@ export const PermissionSimulation = ({
/>
</div>
)}
{subject === ProjectPermissionSub.DynamicSecrets && (
<div>
<MetadataForm control={control} />
</div>
)}
</>
)}
</div>
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { MongoAbility, MongoQuery } from "@casl/ability";
import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react";
@@ -7,7 +8,7 @@ import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries";
import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types";
import { useAccessTreeContext } from "../components";
import { AccessTreeForm, useAccessTreeContext } from "../components";
import { PermissionAccess } from "../types";
import {
createBaseEdge,
@@ -36,6 +37,8 @@ export const useAccessTree = (
) => {
const { currentWorkspace } = useWorkspace();
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
const { control } = useFormContext<AccessTreeForm>();
const metadata = useWatch({ control, name: "metadata" });
const [nodes, setNodes] = useNodesState<Node>([]);
const [edges, setEdges] = useEdgesState<Edge>([]);
const [subject, setSubject] = useState(ProjectPermissionSub.Secrets);
@@ -168,7 +171,8 @@ export const useAccessTree = (
environment,
subject,
secretName,
actionRuleMap
actionRuleMap,
metadata
})
);
@@ -266,7 +270,8 @@ export const useAccessTree = (
subject,
secretName,
setNodes,
setEdges
setEdges,
metadata
]);
return {
@@ -17,6 +17,27 @@ type Props = {
access: PermissionAccess;
} & Pick<ReturnType<typeof createFolderNode>["data"], "actionRuleMap" | "subject">;
type ConditionDisplayProps = {
_key: string;
operator: string;
value: string | string[];
};
const ConditionDisplay = ({ _key: key, value, operator }: ConditionDisplayProps) => {
return (
<li>
<span className="font-medium capitalize text-mineshaft-100">{camelCaseToSpaces(key)}</span>{" "}
<span className="text-mineshaft-200">
{formatedConditionsOperatorNames[operator as PermissionConditionOperators]}
</span>{" "}
<span className="rounded bg-mineshaft-600 p-0.5 font-mono">
{typeof value === "string" ? value : value.join(", ")}
</span>
.
</li>
);
};
export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subject }: Props) => {
let component: ReactElement;
@@ -56,43 +77,58 @@ export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subjec
{actionRuleMap.map((ruleMap, index) => {
const rule = ruleMap[action];
if (
!rule ||
!rule.conditions ||
(!rule.conditions.secretName && !rule.conditions.secretTags)
)
return null;
if (!rule || !rule.conditions) return null;
return (
<li key={`${action}_${index + 1}`}>
<span className={`italic ${rule.inverted ? "text-red" : "text-green"} `}>
{rule.inverted ? "Forbids" : "Allows"}
</span>
<span> when:</span>
{Object.entries(rule.conditions).map(([key, condition]) => (
<ul key={`${action}_${index + 1}_${key}`} className="list-[square] pl-4">
{Object.entries(condition as object).map(([operator, value]) => (
<li key={`${action}_${index + 1}_${key}_${operator}`}>
<span className="font-medium capitalize text-mineshaft-100">
{camelCaseToSpaces(key)}
</span>{" "}
<span className="text-mineshaft-200">
{
formatedConditionsOperatorNames[
operator as PermissionConditionOperators
]
if (
rule.conditions.secretName ||
rule.conditions.secretTags ||
rule.conditions.metadata
) {
return (
<li key={`${action}_${index + 1}`}>
<span className="italic">{rule.inverted ? "Forbids" : "Allows"}</span>
<span> when:</span>
{Object.entries(rule.conditions).map(([key, condition]) => {
if (key.match(/secretPath|environment/)) {
return null;
}
return (
<ul key={`${action}_${index + 1}_${key}`} className="list-[square] pl-4">
{Object.entries(condition as object).map(([operator, value]) => {
if (operator === "$elemMatch") {
return Object.entries(value as object).map(
([nestedKey, nestedCondition]) =>
Object.entries(nestedCondition as object).map(
([nestedOperator, nestedValue]) => (
<ConditionDisplay
_key={`${key} ${nestedKey}`}
operator={nestedOperator}
value={nestedValue}
key={`${action}_${index + 1}_${key}_${operator}_${nestedKey}_${nestedOperator}`}
/>
)
)
);
}
</span>{" "}
<span className={rule.inverted ? "text-red" : "text-green"}>
{typeof value === "string" ? value : value.join(", ")}
</span>
.
</li>
))}
</ul>
))}
</li>
);
return (
<ConditionDisplay
_key={key}
operator={operator}
value={value}
key={`${action}_${index + 1}_${key}_${operator}`}
/>
);
})}
</ul>
);
})}
</li>
);
}
return null;
})}
</ul>
</>
@@ -1,5 +1,5 @@
import { Dispatch, SetStateAction } from "react";
import { faFileImport, faFolder, faKey, faLock } from "@fortawesome/free-solid-svg-icons";
import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Handle, NodeProps, Position } from "@xyflow/react";
@@ -12,15 +12,15 @@ import { createRoleNode } from "../utils";
const getSubjectIcon = (subject: ProjectPermissionSub) => {
switch (subject) {
case ProjectPermissionSub.Secrets:
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-bunker-300" />;
case ProjectPermissionSub.SecretFolders:
return <FontAwesomeIcon icon={faFolder} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.DynamicSecrets:
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-yellow-700" />;
return <FontAwesomeIcon icon={faFingerprint} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.SecretImports:
return <FontAwesomeIcon icon={faFileImport} className="h-4 w-4 text-yellow-700" />;
return <FontAwesomeIcon icon={faFileImport} className="h-4 w-4 text-green-700" />;
default:
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-bunker-300" />;
}
};
@@ -33,6 +33,12 @@ const ACTION_MAP: Record<string, string[] | undefined> = {
]
};
const SUBJECT_HEIGHT_MAP: Record<string, number> = {
[ProjectPermissionSub.DynamicSecrets]: 130,
[ProjectPermissionSub.Secrets]: 85,
default: 64
};
const evaluateCondition = (
value: string,
operator: PermissionConditionOperators,
@@ -52,13 +58,113 @@ const evaluateCondition = (
}
};
const doesConditionMatch = (
conditions: Record<string, any> | undefined,
value: string
): boolean => {
if (!conditions) return true;
return Object.entries(conditions).every(([operator, comparisonValue]) =>
evaluateCondition(value, operator as PermissionConditionOperators, comparisonValue)
);
};
const doBaseConditionsApply = (
ruleConditions: any,
environment: string,
folderPath: string
): boolean => {
return (
doesConditionMatch(ruleConditions?.environment, environment) &&
doesConditionMatch(ruleConditions?.secretPath, folderPath)
);
};
const shouldShowConditionalAccess = (
actionRuleMap: TActionRuleMap,
action: string,
environment: string,
folderPath: string,
conditionalFields: string[]
): boolean => {
return actionRuleMap.some((rule) => {
const ruleConditions = rule[action]?.conditions;
if (!ruleConditions) return false;
// Check if any of the conditional fields are present
const hasConditionalField = conditionalFields.some((field) => ruleConditions[field]);
if (!hasConditionalField) return false;
// Check if base conditions (environment and secretPath) apply
return doBaseConditionsApply(ruleConditions, environment, folderPath);
});
};
const determineAccessLevel = (
hasPermission: boolean,
subject: ProjectPermissionSub,
action: string,
actionRuleMap: TActionRuleMap,
environment: string,
folderPath: string,
secretName: string,
metadata: Array<{ key: string; value: string }>
): PermissionAccess => {
if (!hasPermission) {
return PermissionAccess.None;
}
if (subject === ProjectPermissionSub.Secrets) {
if (
!secretName &&
shouldShowConditionalAccess(actionRuleMap, action, environment, folderPath, [
"secretName",
"secretTags"
])
) {
return PermissionAccess.Partial;
}
} else if (subject === ProjectPermissionSub.DynamicSecrets) {
if (
!metadata.length &&
shouldShowConditionalAccess(actionRuleMap, action, environment, folderPath, ["metadata"])
) {
return PermissionAccess.Partial;
}
}
return PermissionAccess.Full;
};
const checkPermission = (
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>,
subject: ProjectPermissionSub,
action: string,
subjectFields: any
): boolean => {
if (
subject === ProjectPermissionSub.Secrets &&
(action === ProjectPermissionSecretActions.ReadValue ||
action === ProjectPermissionSecretActions.DescribeSecret)
) {
return hasSecretReadValueOrDescribePermission(permissions, action, subjectFields);
}
return permissions.can(
// @ts-expect-error we are not specifying which so can't resolve if valid
action,
abilitySubject(subject, subjectFields)
);
};
export const createFolderNode = ({
folder,
permissions,
environment,
subject,
secretName,
actionRuleMap
actionRuleMap,
metadata
}: {
folder: TSecretFolderWithPath;
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
@@ -66,6 +172,7 @@ export const createFolderNode = ({
subject: ProjectPermissionSub;
secretName: string;
actionRuleMap: TActionRuleMap;
metadata: Array<{ key: string; value: string }>;
}) => {
const actions = Object.fromEntries(
Object.values(ACTION_MAP[subject] ?? Object.values(ProjectPermissionActions)).map((action) => {
@@ -73,74 +180,26 @@ export const createFolderNode = ({
// wrapped in try because while editing certain conditions, if their values are empty it throws an error
try {
let hasPermission: boolean;
const subjectFields = {
secretPath: folder.path,
environment,
secretName: secretName || "*",
secretTags: ["*"]
secretTags: ["*"],
metadata: metadata.length ? metadata : ["*"]
};
if (
subject === ProjectPermissionSub.Secrets &&
(action === ProjectPermissionSecretActions.ReadValue ||
action === ProjectPermissionSecretActions.DescribeSecret)
) {
hasPermission = hasSecretReadValueOrDescribePermission(
permissions,
action,
subjectFields
);
} else {
hasPermission = permissions.can(
// @ts-expect-error we are not specifying which so can't resolve if valid
action,
abilitySubject(subject, subjectFields)
);
}
const hasPermission = checkPermission(permissions, subject, action, subjectFields);
if (hasPermission) {
// we want to show yellow/conditional access if user hasn't specified secret name to fully resolve access
if (
!secretName &&
actionRuleMap.some((el) => {
// we only show conditional if secretName/secretTags are present - environment and path can be directly determined
if (!el[action]?.conditions?.secretName && !el[action]?.conditions?.secretTags)
return false;
// make sure condition applies to env
if (el[action]?.conditions?.environment) {
if (
!Object.entries(el[action]?.conditions?.environment).every(([operator, value]) =>
evaluateCondition(environment, operator as PermissionConditionOperators, value)
)
) {
return false;
}
}
// and applies to path
if (el[action]?.conditions?.secretPath) {
if (
!Object.entries(el[action]?.conditions?.secretPath).every(([operator, value]) =>
evaluateCondition(folder.path, operator as PermissionConditionOperators, value)
)
) {
return false;
}
}
return true;
})
) {
access = PermissionAccess.Partial;
} else {
access = PermissionAccess.Full;
}
} else {
access = PermissionAccess.None;
}
access = determineAccessLevel(
hasPermission,
subject,
action,
actionRuleMap,
environment,
folder.path,
secretName,
metadata
);
} catch (e) {
console.error(e);
access = PermissionAccess.None;
@@ -150,18 +209,7 @@ export const createFolderNode = ({
})
);
let height: number;
switch (subject) {
case ProjectPermissionSub.DynamicSecrets:
height = 130;
break;
case ProjectPermissionSub.Secrets:
height = 85;
break;
default:
height = 64;
}
const height = SUBJECT_HEIGHT_MAP[subject] ?? SUBJECT_HEIGHT_MAP.default;
return {
type: PermissionNode.Folder,
@@ -0,0 +1,51 @@
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { SingleValue } from "react-select";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { TFlyioApp, useFlyioConnectionListApps } from "@app/hooks/api/appConnections/flyio";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
export const FlyioSyncFields = () => {
const { control, setValue } = useFormContext<
TSecretSyncForm & { destination: SecretSync.Flyio }
>();
const connectionId = useWatch({ name: "connection.id", control });
const { data: apps, isLoading: isAppsLoading } = useFlyioConnectionListApps(connectionId, {
enabled: Boolean(connectionId)
});
return (
<>
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.appId", "");
}}
/>
<Controller
name="destinationConfig.appId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="App">
<FilterableSelect
menuPlacement="top"
isLoading={isAppsLoading && Boolean(connectionId)}
isDisabled={!connectionId}
value={apps?.find((v) => v.id === value) ?? null}
onChange={(option) => onChange((option as SingleValue<TFlyioApp>)?.id ?? null)}
options={apps}
placeholder="Select an app..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
</>
);
};
@@ -0,0 +1,112 @@
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { SingleValue } from "react-select";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
import { RENDER_SYNC_SCOPES } from "@app/helpers/secretSyncs";
import {
TRenderService,
useRenderConnectionListServices
} from "@app/hooks/api/appConnections/render";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { RenderSyncScope, RenderSyncType } from "@app/hooks/api/secretSyncs/render-sync";
import { TSecretSyncForm } from "../schemas";
export const RenderSyncFields = () => {
const { control, setValue } = useFormContext<
TSecretSyncForm & { destination: SecretSync.Render }
>();
const connectionId = useWatch({ name: "connection.id", control });
const { data: services = [], isPending: isServicesPending } = useRenderConnectionListServices(
connectionId,
{
enabled: Boolean(connectionId)
}
);
return (
<>
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.serviceId", "");
setValue("destinationConfig.type", RenderSyncType.Env);
setValue("destinationConfig.scope", RenderSyncScope.Service);
}}
/>
<Controller
name="destinationConfig.scope"
control={control}
defaultValue={RenderSyncScope.Service}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Scope"
tooltipClassName="max-w-lg py-3"
tooltipText={
<div className="flex flex-col gap-3">
<p>
Specify how Infisical should manage secrets from Render. The following options are
available:
</p>
<ul className="flex list-disc flex-col gap-3 pl-4">
{Object.values(RENDER_SYNC_SCOPES).map(({ name, description }) => {
return (
<li key={name}>
<p className="text-mineshaft-300">
<span className="font-medium text-bunker-200">{name}</span>: {description}
</p>
</li>
);
})}
</ul>
</div>
}
>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select a scope..."
dropdownContainerClassName="max-w-none"
>
{Object.values(RenderSyncScope).map((scope) => (
<SelectItem className="capitalize" value={scope} key={scope}>
{scope.replace("-", " ")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="destinationConfig.serviceId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error?.message)} label="Service">
<FilterableSelect
isLoading={isServicesPending && Boolean(connectionId)}
isDisabled={!connectionId}
value={services ? (services.find((service) => service.id === value) ?? []) : []}
onChange={(option) => {
onChange((option as SingleValue<TRenderService>)?.id ?? null);
setValue(
"destinationConfig.serviceName",
(option as SingleValue<TRenderService>)?.name ?? ""
);
}}
options={services}
placeholder="Select a service..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
/>
</>
);
};
@@ -11,12 +11,14 @@ import { AzureDevOpsSyncFields } from "./AzureDevOpsSyncFields";
import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields";
import { CamundaSyncFields } from "./CamundaSyncFields";
import { DatabricksSyncFields } from "./DatabricksSyncFields";
import { FlyioSyncFields } from "./FlyioSyncFields";
import { GcpSyncFields } from "./GcpSyncFields";
import { GitHubSyncFields } from "./GitHubSyncFields";
import { HCVaultSyncFields } from "./HCVaultSyncFields";
import { HerokuSyncFields } from "./HerokuSyncFields";
import { HumanitecSyncFields } from "./HumanitecSyncFields";
import { OCIVaultSyncFields } from "./OCIVaultSyncFields";
import { RenderSyncFields } from "./RenderSyncFields";
import { TeamCitySyncFields } from "./TeamCitySyncFields";
import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields";
import { VercelSyncFields } from "./VercelSyncFields";
@@ -64,6 +66,10 @@ export const SecretSyncDestinationFields = () => {
return <OnePassSyncFields />;
case SecretSync.Heroku:
return <HerokuSyncFields />;
case SecretSync.Render:
return <RenderSyncFields />;
case SecretSync.Flyio:
return <FlyioSyncFields />;
default:
throw new Error(`Unhandled Destination Config Field: ${destination}`);
}
@@ -53,6 +53,8 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
case SecretSync.OnePass:
case SecretSync.OCIVault:
case SecretSync.Heroku:
case SecretSync.Render:
case SecretSync.Flyio:
AdditionalSyncOptionsFieldsComponent = null;
break;
default:
@@ -0,0 +1,12 @@
import { useFormContext } from "react-hook-form";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const FlyioSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Flyio }>();
const appId = watch("destinationConfig.appId");
return <GenericFieldLabel label="App ID">{appId}</GenericFieldLabel>;
};
@@ -0,0 +1,18 @@
import { useFormContext } from "react-hook-form";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const RenderSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Render }>();
const serviceName = watch("destinationConfig.serviceName");
const scope = watch("destinationConfig.scope");
return (
<>
<GenericFieldLabel label="Scope">{scope}</GenericFieldLabel>
<GenericFieldLabel label="Service">{serviceName}</GenericFieldLabel>
</>
);
};
@@ -20,6 +20,7 @@ import { AzureDevOpsSyncReviewFields } from "./AzureDevOpsSyncReviewFields";
import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields";
import { CamundaSyncReviewFields } from "./CamundaSyncReviewFields";
import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields";
import { FlyioSyncReviewFields } from "./FlyioSyncReviewFields";
import { GcpSyncReviewFields } from "./GcpSyncReviewFields";
import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields";
import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields";
@@ -27,6 +28,7 @@ import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields";
import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields";
import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields";
import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields";
import { RenderSyncReviewFields } from "./RenderSyncReviewFields";
import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields";
import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields";
import { VercelSyncReviewFields } from "./VercelSyncReviewFields";
@@ -108,6 +110,12 @@ export const SecretSyncReviewFields = () => {
case SecretSync.Heroku:
DestinationFieldsComponent = <HerokuSyncReviewFields />;
break;
case SecretSync.Render:
DestinationFieldsComponent = <RenderSyncReviewFields />;
break;
case SecretSync.Flyio:
DestinationFieldsComponent = <FlyioSyncReviewFields />;
break;
default:
throw new Error(`Unhandled Destination Review Fields: ${destination}`);
}
@@ -1,17 +1,45 @@
import { useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { subject } from "@casl/ability";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useWorkspace } from "@app/context";
import { useProjectPermission, useWorkspace } from "@app/context";
import {
ProjectPermissionSecretSyncActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { TSecretSyncForm } from "./schemas";
export const SecretSyncSourceFields = () => {
const { control, watch } = useFormContext<TSecretSyncForm>();
const { control, watch, setError, clearErrors } = useFormContext<TSecretSyncForm>();
const { permission } = useProjectPermission();
const { currentWorkspace } = useWorkspace();
const selectedEnvironment = watch("environment");
const selectedSecretPath = watch("secretPath");
useEffect(() => {
const hasAccessToSource =
selectedEnvironment &&
permission.can(
ProjectPermissionSecretSyncActions.Create,
subject(ProjectPermissionSub.SecretSyncs, {
environment: selectedEnvironment.slug,
secretPath: selectedSecretPath
})
);
if (!hasAccessToSource) {
setError("secretPath", {
message: "You do not have permission to create secret syncs in this environment or path."
});
} else {
clearErrors("secretPath");
}
}, [selectedEnvironment, selectedSecretPath]);
return (
<>
@@ -0,0 +1,13 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const FlyioSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.Flyio),
destinationConfig: z.object({
appId: z.string().trim().min(1, "App ID required")
})
})
);
@@ -0,0 +1,19 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { RenderSyncScope, RenderSyncType } from "@app/hooks/api/secretSyncs/render-sync";
export const RenderSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.Render),
destinationConfig: z.discriminatedUnion("scope", [
z.object({
scope: z.literal(RenderSyncScope.Service),
serviceId: z.string().trim().min(1, "Service is required"),
serviceName: z.string().trim().optional(),
type: z.nativeEnum(RenderSyncType)
})
])
})
);
@@ -8,12 +8,14 @@ import { AzureDevOpsSyncDestinationSchema } from "./azure-devops-sync-destinatio
import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema";
import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema";
import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema";
import { FlyioSyncDestinationSchema } from "./flyio-sync-destination-schema";
import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema";
import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema";
import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema";
import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema";
import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema";
import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema";
import { RenderSyncDestinationSchema } from "./render-sync-destination-schema";
import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schema";
import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema";
import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema";
@@ -37,7 +39,9 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
TeamCitySyncDestinationSchema,
OCIVaultSyncDestinationSchema,
OnePassSyncDestinationSchema,
HerokuSyncDestinationSchema
HerokuSyncDestinationSchema,
RenderSyncDestinationSchema,
FlyioSyncDestinationSchema
]);
export const SecretSyncFormSchema = SecretSyncUnionSchema;
@@ -70,7 +70,15 @@ export const Pagination = ({
key={`pagination-per-page-options-${perPageOption}`}
icon={perPage === perPageOption && <FontAwesomeIcon size="sm" icon={faCheck} />}
iconPos="right"
onClick={() => onChangePerPage(perPageOption)}
onClick={() => {
const totalPages = Math.ceil(count / perPageOption);
if (page > totalPages) {
onChangePage(totalPages);
}
onChangePerPage(perPageOption);
}}
>
{perPageOption} rows per page
</DropdownMenuItem>
@@ -137,7 +137,7 @@ export const SecretPathInput = ({
maxHeight: "var(--radix-select-content-available-height)"
}}
>
<div className="max-h-[25vh] w-full flex-col items-center justify-center overflow-y-scroll rounded-md text-white">
<div className="thin-scrollbar max-h-[25vh] w-full flex-col items-center justify-center overflow-y-scroll rounded-md text-white">
{suggestions.map((suggestion, i) => (
<div
tabIndex={0}