fix: secret sync form

This commit is contained in:
Piyush Gupta
2025-12-10 21:05:13 +05:30
parent c29d42a7b7
commit 271a7a1447
8 changed files with 629 additions and 9 deletions

View File

@@ -113,7 +113,7 @@ export const registerOctopusDeployConnectionRouter = async (server: FastifyZodPr
name: z.string()
})
.array(),
tenantTags: z
roles: z
.object({
id: z.string(),
name: z.string()

View File

@@ -72,8 +72,11 @@ export const getOctopusDeploySpaces = async (
}));
} catch (error: unknown) {
if (error instanceof AxiosError) {
const errorMessage = (error.response?.data as { error: { ErrorMessage: string } })?.error?.ErrorMessage;
throw new BadRequestError({
message: `Failed to list Octopus Deploy spaces: ${error.message || "Unknown error"}`
message: `Failed to list Octopus Deploy spaces: ${errorMessage || "Unknown error"}`,
error: error.response?.data
});
}
@@ -107,8 +110,11 @@ export const getOctopusDeployProjects = async (
}));
} catch (error: unknown) {
if (error instanceof AxiosError) {
const errorMessage = (error.response?.data as { error: { ErrorMessage: string } })?.error?.ErrorMessage;
throw new BadRequestError({
message: `Failed to list Octopus Deploy projects: ${error.message || "Unknown error"}`
message: `Failed to list Octopus Deploy projects: ${errorMessage || "Unknown error"}`,
error: error.response?.data
});
}
@@ -146,9 +152,9 @@ export const getOctopusDeployScopeValues = async (
id: environment.Id,
name: environment.Name
})),
tenantTags: ScopeValues.TenantTags.map((tenantTag) => ({
id: tenantTag.Id,
name: tenantTag.Name
roles: ScopeValues.Roles.map((role) => ({
id: role.Id,
name: role.Name
})),
machines: ScopeValues.Machines.map((machine) => ({
id: machine.Id,
@@ -171,8 +177,11 @@ export const getOctopusDeployScopeValues = async (
return scopeValues;
} catch (error: unknown) {
if (error instanceof AxiosError) {
const errorMessage = (error.response?.data as { error: { ErrorMessage: string } })?.error?.ErrorMessage;
throw new BadRequestError({
message: `Failed to get Octopus Deploy scope values: ${error.message || "Unknown error"}`
message: `Failed to get Octopus Deploy scope values: ${errorMessage || "Unknown error"}`,
error: error.response?.data
});
}

View File

@@ -51,7 +51,7 @@ export type TOctopusDeployProject = {
export type TOctopusDeployScopeValuesResponse = {
ScopeValues: {
Environments: { Id: string; Name: string }[];
TenantTags: { Id: string; Name: string }[];
Roles: { Id: string; Name: string }[];
Machines: { Id: string; Name: string }[];
Processes: { Id: string; Name: string }[];
Actions: { Id: string; Name: string }[];
@@ -61,7 +61,7 @@ export type TOctopusDeployScopeValuesResponse = {
export type TOctopusDeployScopeValues = {
environments: { id: string; name: string }[];
tenantTags: { id: string; name: string }[];
roles: { id: string; name: string }[];
machines: { id: string; name: string }[];
processes: { id: string; name: string }[];
actions: { id: string; name: string }[];

View File

@@ -0,0 +1,81 @@
import { z } from "zod";
import { SecretSyncs } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
BaseSecretSyncSchema,
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps";
export enum OctopusDeploySyncScope {
Project = "project"
}
const OctopusDeploySyncDestinationConfigBaseSchema = z.object({
spaceId: z
.string()
.min(1, "Space ID required")
.describe(SecretSyncs.DESTINATION_CONFIG.OCTOPUS_DEPLOY.spaceId || "Octopus Deploy Space ID"),
scope: z.nativeEnum(OctopusDeploySyncScope).default(OctopusDeploySyncScope.Project)
});
export const OctopusDeploySyncDestinationConfigSchema = z.intersection(
OctopusDeploySyncDestinationConfigBaseSchema,
z.discriminatedUnion("scope", [
z.object({
scope: z.literal(OctopusDeploySyncScope.Project),
projectId: z
.string()
.min(1, "Project ID required")
.describe(SecretSyncs.DESTINATION_CONFIG.OCTOPUS_DEPLOY.projectId),
scopeValues: z
.object({
environments: z.array(z.string()).optional(),
roles: z.array(z.string()).optional(),
machines: z.array(z.string()).optional(),
processes: z.array(z.string()).optional(),
actions: z.array(z.string()).optional(),
channels: z.array(z.string()).optional()
})
.optional()
.describe(SecretSyncs.DESTINATION_CONFIG.OCTOPUS_DEPLOY.scopeValues)
})
])
);
const OctopusDeploySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false };
export const OctopusDeploySyncSchema = BaseSecretSyncSchema(SecretSync.OctopusDeploy, OctopusDeploySyncOptionsConfig)
.extend({
destination: z.literal(SecretSync.OctopusDeploy),
destinationConfig: OctopusDeploySyncDestinationConfigSchema
})
.describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.OctopusDeploy] }));
export const CreateOctopusDeploySyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.OctopusDeploy,
OctopusDeploySyncOptionsConfig
).extend({
destinationConfig: OctopusDeploySyncDestinationConfigSchema
});
export const UpdateOctopusDeploySyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.OctopusDeploy,
OctopusDeploySyncOptionsConfig
).extend({
destinationConfig: OctopusDeploySyncDestinationConfigSchema.optional()
});
export const OctopusDeploySyncListItemSchema = z
.object({
name: z.literal("Octopus Deploy"),
connection: z.literal(AppConnection.OctopusDeploy),
destination: z.literal(SecretSync.OctopusDeploy),
canImportSecrets: z.literal(false)
})
.describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.OctopusDeploy] }));

View File

@@ -0,0 +1,437 @@
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { MultiValue, SingleValue } from "react-select";
import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
FilterableSelect,
FormControl,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import {
useOctopusDeployConnectionGetScopeValues,
useOctopusDeployConnectionListProjects,
useOctopusDeployConnectionListSpaces
} from "@app/hooks/api/appConnections/octopus-deploy/queries";
import {
TOctopusDeployProject,
TOctopusDeploySpace,
TScopeValueOption
} from "@app/hooks/api/appConnections/octopus-deploy/types";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { OctopusDeploySyncScope } from "@app/hooks/api/secretSyncs/types/octopus-deploy-sync";
import { TSecretSyncForm } from "../schemas";
export const OctopusDeploySyncFields = () => {
const { control, setValue, getValues } = useFormContext<
TSecretSyncForm & { destination: SecretSync.OctopusDeploy }
>();
const connectionId = useWatch({ name: "connection.id", control });
const spaceId = useWatch({ name: "destinationConfig.spaceId", control });
const scope = useWatch({ name: "destinationConfig.scope", control });
const projectId = useWatch({ name: "destinationConfig.projectId", control });
const { data: spaces = [], isLoading: isSpacesLoading } = useOctopusDeployConnectionListSpaces(
connectionId,
{
enabled: Boolean(connectionId)
}
);
console.log(getValues());
const { data: projects = [], isLoading: isProjectsLoading } =
useOctopusDeployConnectionListProjects(connectionId, spaceId, {
enabled: Boolean(connectionId && spaceId && scope)
});
const { data: scopeValuesData, isLoading: isScopeValuesLoading } =
useOctopusDeployConnectionGetScopeValues(connectionId, spaceId, projectId, {
enabled: Boolean(connectionId && spaceId && projectId && scope)
});
return (
<>
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.spaceId", "");
setValue("destinationConfig.projectId", "");
setValue("destinationConfig.scopeValues", undefined);
}}
/>
<Controller
name="destinationConfig.spaceId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Space"
helperText={
<Tooltip
className="max-w-md"
content="Select the Octopus Deploy space where your project is located."
>
<div>
<span>Don&#39;t see the space you&#39;re looking for?</span>{" "}
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</div>
</Tooltip>
}
>
<FilterableSelect
menuPlacement="top"
isLoading={isSpacesLoading && Boolean(connectionId)}
isDisabled={!connectionId}
value={spaces?.find((space) => space.id === value) ?? null}
onChange={(option) => {
const selectedSpace = option as SingleValue<TOctopusDeploySpace>;
onChange(selectedSpace?.id ?? null);
setValue("destinationConfig.projectId", "");
setValue("destinationConfig.scopeValues", undefined);
}}
options={spaces}
placeholder={spaces?.length ? "Select a space..." : "No spaces found..."}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
<Controller
name="destinationConfig.scope"
control={control}
defaultValue={OctopusDeploySyncScope.Project}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Scope"
helperText="Select the scope for this sync configuration."
>
<Select
value={value || OctopusDeploySyncScope.Project}
onValueChange={(val) => {
onChange(val);
setValue("destinationConfig.projectId", "");
setValue("destinationConfig.scopeValues", undefined);
}}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select a scope..."
dropdownContainerClassName="max-w-none"
>
{Object.values(OctopusDeploySyncScope).map((scopeValue) => (
<SelectItem className="capitalize" value={scopeValue} key={scopeValue}>
{scopeValue}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{scope === OctopusDeploySyncScope.Project && (
<Controller
name="destinationConfig.projectId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project"
helperText={
<Tooltip
className="max-w-md"
content="Ensure the project exists in the selected space."
>
<div>
<span>Don&#39;t see the project you&#39;re looking for?</span>{" "}
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</div>
</Tooltip>
}
>
<FilterableSelect
menuPlacement="top"
isLoading={isProjectsLoading && Boolean(connectionId && spaceId)}
isDisabled={Boolean(!connectionId || !spaceId)}
value={projects?.find((project) => project.id === value) ?? null}
onChange={(option) => {
onChange((option as SingleValue<TOctopusDeployProject>)?.id ?? null);
setValue("destinationConfig.scopeValues", undefined);
}}
options={projects}
placeholder={
spaceId && projects?.length ? "Select a project..." : "No projects found..."
}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
)}
{scope === OctopusDeploySyncScope.Project && projectId && (
<Accordion type="single" collapsible className="w-full bg-mineshaft-700">
<AccordionItem value="scope-values" className="overflow-visible">
<AccordionTrigger>Scope Values (Optional)</AccordionTrigger>
<AccordionContent className="max-h-96 overflow-y-auto">
<div className="grid grid-cols-2 gap-x-4">
{/* Environments */}
<Controller
name="destinationConfig.scopeValues.environments"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Environments"
isOptional
>
<FilterableSelect
isMulti
menuPlacement="bottom"
menuPosition="absolute"
isLoading={isScopeValuesLoading}
value={
scopeValuesData?.environments?.filter((opt) =>
(value || []).includes(opt.id)
) || []
}
onChange={(options) => {
const selectedIds = (options as MultiValue<TScopeValueOption>).map(
(opt) => opt.id
);
onChange(selectedIds.length > 0 ? selectedIds : undefined);
}}
options={scopeValuesData?.environments || []}
placeholder={
scopeValuesData?.environments?.length
? "Select environments..."
: "No environments found..."
}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
{/* Target Tags */}
<Controller
name="destinationConfig.scopeValues.roles"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Target Tags"
isOptional
>
<FilterableSelect
isMulti
menuPlacement="bottom"
menuPosition="absolute"
isLoading={isScopeValuesLoading}
value={
scopeValuesData?.roles?.filter((opt) => (value || []).includes(opt.id)) ||
[]
}
onChange={(options) => {
const selectedIds = (options as MultiValue<TScopeValueOption>).map(
(opt) => opt.id
);
onChange(selectedIds.length > 0 ? selectedIds : undefined);
}}
options={scopeValuesData?.roles || []}
placeholder={
scopeValuesData?.roles?.length
? "Select target tags..."
: "No target tags found..."
}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
{/* Targets */}
<Controller
name="destinationConfig.scopeValues.machines"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Targets"
isOptional
>
<FilterableSelect
isMulti
menuPlacement="top"
menuPosition="absolute"
isLoading={isScopeValuesLoading}
value={
scopeValuesData?.machines?.filter((opt) =>
(value || []).includes(opt.id)
) || []
}
onChange={(options) => {
const selectedIds = (options as MultiValue<TScopeValueOption>).map(
(opt) => opt.id
);
onChange(selectedIds.length > 0 ? selectedIds : undefined);
}}
options={scopeValuesData?.machines || []}
placeholder={
scopeValuesData?.machines?.length
? "Select targets..."
: "No targets found..."
}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
{/* Processes */}
<Controller
name="destinationConfig.scopeValues.processes"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Processes"
isOptional
>
<FilterableSelect
isMulti
menuPlacement="top"
menuPosition="absolute"
isLoading={isScopeValuesLoading}
value={
scopeValuesData?.processes?.filter((opt) =>
(value || []).includes(opt.id)
) || []
}
onChange={(options) => {
const selectedIds = (options as MultiValue<TScopeValueOption>).map(
(opt) => opt.id
);
onChange(selectedIds.length > 0 ? selectedIds : undefined);
}}
options={scopeValuesData?.processes || []}
placeholder={
scopeValuesData?.processes?.length
? "Select processes..."
: "No processes found..."
}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
{/* Deployment Steps */}
<Controller
name="destinationConfig.scopeValues.actions"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Deployment Steps"
isOptional
>
<FilterableSelect
isMulti
menuPlacement="top"
menuPosition="absolute"
isLoading={isScopeValuesLoading}
value={
scopeValuesData?.actions?.filter((opt) =>
(value || []).includes(opt.id)
) || []
}
onChange={(options) => {
const selectedIds = (options as MultiValue<TScopeValueOption>).map(
(opt) => opt.id
);
onChange(selectedIds.length > 0 ? selectedIds : undefined);
}}
options={scopeValuesData?.actions || []}
placeholder={
scopeValuesData?.actions?.length
? "Select deployment steps..."
: "No deployment steps found..."
}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
{/* Channels */}
<Controller
name="destinationConfig.scopeValues.channels"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Channels"
isOptional
>
<FilterableSelect
isMulti
menuPlacement="top"
menuPosition="absolute"
isLoading={isScopeValuesLoading}
value={
scopeValuesData?.channels?.filter((opt) =>
(value || []).includes(opt.id)
) || []
}
onChange={(options) => {
const selectedIds = (options as MultiValue<TScopeValueOption>).map(
(opt) => opt.id
);
onChange(selectedIds.length > 0 ? selectedIds : undefined);
}}
options={scopeValuesData?.channels || []}
placeholder={
scopeValuesData?.channels?.length
? "Select channels..."
: "No channels found..."
}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</>
);
};

View File

@@ -0,0 +1,32 @@
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 { OctopusDeploySyncScope } from "@app/hooks/api/secretSyncs/types/octopus-deploy-sync";
export const OctopusDeploySyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.OctopusDeploy),
destinationConfig: z.intersection(
z.object({
spaceId: z.string().trim().min(1, { message: "Space ID is required" })
}),
z.discriminatedUnion("scope", [
z.object({
scope: z.literal(OctopusDeploySyncScope.Project),
projectId: z.string().trim().min(1, { message: "Project ID is required" }),
scopeValues: z
.object({
environments: z.array(z.string()).optional(),
roles: z.array(z.string()).optional(),
machines: z.array(z.string()).optional(),
processes: z.array(z.string()).optional(),
actions: z.array(z.string()).optional(),
channels: z.array(z.string()).optional()
})
.optional()
})
])
)
})
);

View File

@@ -0,0 +1,26 @@
export type TOctopusDeploySpace = {
id: string;
name: string;
slug: string;
isDefault: boolean;
};
export type TOctopusDeployProject = {
id: string;
name: string;
slug: string;
};
export type TOctopusDeployScopeValues = {
environments: { id: string; name: string }[];
roles: { id: string; name: string }[];
machines: { id: string; name: string }[];
processes: { id: string; name: string }[];
actions: { id: string; name: string }[];
channels: { id: string; name: string }[];
};
export type TScopeValueOption = {
id: string;
name: string;
};

View File

@@ -0,0 +1,35 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export enum OctopusDeploySyncScope {
Project = "project"
}
type TOctopusDeploySyncDestinationConfigProject = {
scope: OctopusDeploySyncScope.Project;
projectId: string;
scopeValues?: {
environments?: string[];
roles?: string[];
machines?: string[];
processes?: string[];
actions?: string[];
channels?: string[];
};
};
type TOctopusDeploySyncDestinationConfig = {
spaceId: string;
} & TOctopusDeploySyncDestinationConfigProject;
export type TOctopusDeploySync = TRootSecretSync & {
destination: SecretSync.OctopusDeploy;
destinationConfig: TOctopusDeploySyncDestinationConfig;
connection: {
app: AppConnection.OctopusDeploy;
name: string;
id: string;
};
};