diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 69653a0be..72b283a0f 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2302,6 +2302,9 @@ export const AppConnections = { DIGITAL_OCEAN_APP_PLATFORM: { apiToken: "The API token used to authenticate with Digital Ocean App Platform." }, + NETLIFY: { + accessToken: "The Access token used to authenticate with Netlify." + }, OKTA: { instanceUrl: "The URL used to access your Okta organization.", apiToken: "The API token used to authenticate with Okta." @@ -2533,6 +2536,13 @@ export const SecretSyncs = { workspaceSlug: "The Bitbucket Workspace slug to sync secrets to.", repositorySlug: "The Bitbucket Repository slug to sync secrets to.", environmentId: "The Bitbucket Deployment Environment uuid to sync secrets to." + }, + NETLIFY: { + accountId: "The ID of the Netlify account to sync secrets to.", + accountName: "The name of the Netlify account to sync secrets to.", + siteName: "The name of the Netlify site to sync secrets to.", + siteId: "The ID of the Netlify site to sync secrets to.", + context: "The Netlify context to sync secrets to." } } }; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 8468d7305..f184f2001 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -75,6 +75,10 @@ import { import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap"; import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql"; import { MySqlConnectionListItemSchema, SanitizedMySqlConnectionSchema } from "@app/services/app-connection/mysql"; +import { + NetlifyConnectionListItemSchema, + SanitizedNetlifyConnectionSchema +} from "@app/services/app-connection/netlify"; import { OktaConnectionListItemSchema, SanitizedOktaConnectionSchema } from "@app/services/app-connection/okta"; import { PostgresConnectionListItemSchema, @@ -145,6 +149,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedChecklyConnectionSchema.options, ...SanitizedSupabaseConnectionSchema.options, ...SanitizedDigitalOceanConnectionSchema.options, + ...SanitizedNetlifyConnectionSchema.options, ...SanitizedOktaConnectionSchema.options ]); @@ -184,6 +189,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ ChecklyConnectionListItemSchema, SupabaseConnectionListItemSchema, DigitalOceanConnectionListItemSchema, + NetlifyConnectionListItemSchema, OktaConnectionListItemSchema ]); diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 6744e46b3..28e3b4e49 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -26,6 +26,7 @@ import { registerHumanitecConnectionRouter } from "./humanitec-connection-router import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; +import { registerNetlifyConnectionRouter } from "./netlify-connection-router"; import { registerOktaConnectionRouter } from "./okta-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerRailwayConnectionRouter } from "./railway-connection-router"; @@ -76,5 +77,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Netlify, + server, + sanitizedResponseSchema: SanitizedNetlifyConnectionSchema, + createSchema: CreateNetlifyConnectionSchema, + updateSchema: UpdateNetlifyConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/accounts`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + accounts: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const accounts = await server.services.appConnection.netlify.listAccounts(connectionId, req.permission); + + return { accounts }; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/accounts/:accountId/sites`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid(), + accountId: z.string() + }), + response: { + 200: z.object({ + sites: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId, accountId } = req.params; + + const sites = await server.services.appConnection.netlify.listSites(connectionId, req.permission, accountId); + + return { sites }; + } + }); +}; diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 03cf9d053..fed56277e 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -21,6 +21,7 @@ import { registerGitLabSyncRouter } from "./gitlab-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerNetlifySyncRouter } from "./netlify-sync-router"; import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; import { registerSupabaseSyncRouter } from "./supabase-sync-router"; @@ -61,5 +62,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.Netlify, + server, + responseSchema: NetlifySyncSchema, + createSchema: CreateNetlifySyncSchema, + updateSchema: UpdateNetlifySyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index 0388ab53e..71e8b2cca 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -44,6 +44,7 @@ import { GitLabSyncListItemSchema, GitLabSyncSchema } from "@app/services/secret import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault"; import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku"; import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec"; +import { NetlifySyncListItemSchema, NetlifySyncSchema } from "@app/services/secret-sync/netlify"; import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas"; import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas"; import { SupabaseSyncListItemSchema, SupabaseSyncSchema } from "@app/services/secret-sync/supabase"; @@ -82,6 +83,7 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ RailwaySyncSchema, ChecklySyncSchema, DigitalOceanAppPlatformSyncSchema, + NetlifySyncSchema, BitbucketSyncSchema ]); @@ -114,6 +116,7 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ RailwaySyncListItemSchema, ChecklySyncListItemSchema, SupabaseSyncListItemSchema, + NetlifySyncListItemSchema, BitbucketSyncListItemSchema ]); diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 833522cbb..263cbdd9b 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -34,6 +34,7 @@ export enum AppConnection { Checkly = "checkly", Supabase = "supabase", DigitalOcean = "digital-ocean", + Netlify = "netlify", Okta = "okta" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index d72fca654..9ffc358b6 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -97,6 +97,7 @@ import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnection import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; +import { getNetlifyConnectionListItem, validateNetlifyConnectionCredentials } from "./netlify"; import { getOktaConnectionListItem, OktaConnectionMethod, validateOktaConnectionCredentials } from "./okta"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway"; @@ -163,6 +164,7 @@ export const listAppConnectionOptions = () => { getChecklyConnectionListItem(), getSupabaseConnectionListItem(), getDigitalOceanConnectionListItem(), + getNetlifyConnectionListItem(), getOktaConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -251,7 +253,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.DigitalOcean]: validateDigitalOceanConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService); @@ -381,6 +384,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Checkly]: platformManagedCredentialsNotSupported, [AppConnection.Supabase]: platformManagedCredentialsNotSupported, [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, + [AppConnection.Netlify]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 603d30ccd..155b68ce1 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -36,6 +36,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Checkly]: "Checkly", [AppConnection.Supabase]: "Supabase", [AppConnection.DigitalOcean]: "DigitalOcean App Platform", + [AppConnection.Netlify]: "Netlify", [AppConnection.Okta]: "Okta" }; @@ -75,5 +76,6 @@ export const APP_CONNECTION_PLAN_MAP: Record { + return { + name: "Netlify" as const, + app: AppConnection.Netlify as const, + methods: Object.values(NetlifyConnectionMethod) + }; +}; + +export const validateNetlifyConnectionCredentials = async (config: TNetlifyConnectionConfig) => { + try { + await NetlifyPublicAPI.healthcheck(config); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to validate connection - verify credentials" + }); + } + + return config.credentials; +}; diff --git a/backend/src/services/app-connection/netlify/netlify-connection-public-client.ts b/backend/src/services/app-connection/netlify/netlify-connection-public-client.ts new file mode 100644 index 000000000..d1328539b --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-public-client.ts @@ -0,0 +1,261 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable class-methods-use-this */ +import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxiosError } from "axios"; + +import { createRequestClient } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { NetlifyConnectionMethod } from "./netlify-connection-constants"; +import { TNetlifyAccount, TNetlifyConnectionConfig, TNetlifySite, TNetlifyVariable } from "./netlify-connection-types"; + +export function getNetlifyAuthHeaders(connection: TNetlifyConnectionConfig): Record { + switch (connection.method) { + case NetlifyConnectionMethod.AccessToken: + return { + Authorization: `Bearer ${connection.credentials.accessToken}` + }; + default: + throw new Error(`Unsupported Netlify connection method`); + } +} + +export function getNetlifyRatelimiter(response: AxiosResponse): { + maxAttempts: number; + isRatelimited: boolean; + wait: () => Promise; +} { + const wait = (seconds: number = 60) => { + return new Promise((res) => { + setTimeout(res, seconds * 1000); + }); + }; + + let remaining = parseInt(response.headers["X-RateLimit-Remaining"] as string, 10); + let isRatelimited = response.status === HttpStatusCode.TooManyRequests; + + if (isRatelimited) { + if (Math.round(remaining) > 0) { + isRatelimited = true; + remaining += 1; // Jitter to ensure we wait at least 1 second + } else { + remaining = 60; + } + } + + return { + isRatelimited, + wait: () => wait(remaining), + maxAttempts: 3 + }; +} + +type NetlifyParams = { + account_id: string; + context_name?: string; + site_id?: string; +}; + +class NetlifyPublicClient { + private client: AxiosInstance; + + constructor() { + this.client = createRequestClient({ + baseURL: `${IntegrationUrls.NETLIFY_API_URL}/api/v1`, + headers: { + "Content-Type": "application/json" + } + }); + } + + async send(connection: TNetlifyConnectionConfig, config: AxiosRequestConfig, retryAttempt = 0): Promise { + const response = await this.client.request({ + ...config, + timeout: 1000 * 60, // 60 seconds timeout + validateStatus: (status) => (status >= 200 && status < 300) || status === HttpStatusCode.TooManyRequests, + headers: getNetlifyAuthHeaders(connection) + }); + const limiter = getNetlifyRatelimiter(response); + + if (limiter.isRatelimited && retryAttempt <= limiter.maxAttempts) { + await limiter.wait(); + return this.send(connection, config, retryAttempt + 1); + } + + return response.data; + } + + healthcheck(connection: TNetlifyConnectionConfig) { + switch (connection.method) { + case NetlifyConnectionMethod.AccessToken: + return this.getNetlifyAccounts(connection); + default: + throw new Error(`Unsupported Netlify connection method`); + } + } + + async getVariables( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + limit: number = 50, + page: number = 1 + ) { + const res = await this.send(connection, { + method: "GET", + url: `/accounts/${account_id}/env`, + params: { + ...params, + limit, + page + } + }); + + return res; + } + + async createVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + ...variables: TNetlifyVariable[] + ) { + const res = await this.send(connection, { + method: "POST", + url: `/accounts/${account_id}/env`, + data: variables, + params + }); + + return res; + } + + async updateVariableValue( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: TNetlifyVariable + ) { + const res = await this.send(connection, { + method: "PATCH", + url: `/accounts/${account_id}/env/${variable.key}`, + data: variable, + params + }); + + return res; + } + + async updateVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: TNetlifyVariable + ) { + const res = await this.send(connection, { + method: "PUT", + url: `/accounts/${account_id}/env/${variable.key}`, + data: variable, + params + }); + + return res; + } + + async getVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: Pick + ) { + try { + const res = await this.send(connection, { + method: "GET", + url: `/accounts/${account_id}/env/${variable.key}`, + params + }); + + return res; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + + throw error; + } + } + + async upsertVariable(connection: TNetlifyConnectionConfig, params: NetlifyParams, variable: TNetlifyVariable) { + const res = await this.getVariable(connection, params, variable); + + if (!res) { + return this.createVariable(connection, params, variable); + } + + if (res.is_secret) { + await this.deleteVariable(connection, params, variable); + return this.createVariable(connection, params, variable); + } + + return this.updateVariable(connection, params, variable); + } + + async deleteVariable( + connection: TNetlifyConnectionConfig, + { account_id, ...params }: NetlifyParams, + variable: Pick + ) { + try { + const res = await this.send(connection, { + method: "DELETE", + url: `/accounts/${account_id}/env/${variable.key}`, + params + }); + + return res; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + + throw error; + } + } + + async deleteVariableValue( + connection: TNetlifyConnectionConfig, + { account_id, value_id, ...params }: NetlifyParams & { value_id: string }, + variable: Pick + ) { + try { + const res = await this.send(connection, { + method: "DELETE", + url: `/accounts/${account_id}/${variable.key}/value/${value_id}`, + params + }); + + return res; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + + throw error; + } + } + + async getSites(connection: TNetlifyConnectionConfig, accountId: string) { + const res = await this.send(connection, { + method: "GET", + url: `/${accountId}/sites` + }); + + return res; + } + + async getNetlifyAccounts(connection: TNetlifyConnectionConfig) { + const res = await this.send(connection, { + method: "GET", + url: `/accounts` + }); + + return res; + } +} + +export const NetlifyPublicAPI = new NetlifyPublicClient(); diff --git a/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts b/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts new file mode 100644 index 000000000..45fb89760 --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts @@ -0,0 +1,67 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { NetlifyConnectionMethod } from "./netlify-connection-constants"; + +export const NetlifyConnectionMethodSchema = z + .nativeEnum(NetlifyConnectionMethod) + .describe(AppConnections.CREATE(AppConnection.Netlify).method); + +export const NetlifyConnectionAccessTokenCredentialsSchema = z.object({ + accessToken: z + .string() + .trim() + .min(1, "Access Token required") + .max(255) + .describe(AppConnections.CREDENTIALS.NETLIFY.accessToken) +}); + +const BaseNetlifyConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Netlify) +}); + +export const NetlifyConnectionSchema = BaseNetlifyConnectionSchema.extend({ + method: NetlifyConnectionMethodSchema, + credentials: NetlifyConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedNetlifyConnectionSchema = z.discriminatedUnion("method", [ + BaseNetlifyConnectionSchema.extend({ + method: NetlifyConnectionMethodSchema, + credentials: NetlifyConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateNetlifyConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: NetlifyConnectionMethodSchema, + credentials: NetlifyConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Netlify).credentials + ) + }) +]); + +export const CreateNetlifyConnectionSchema = ValidateNetlifyConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Netlify) +); + +export const UpdateNetlifyConnectionSchema = z + .object({ + credentials: NetlifyConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Netlify).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Netlify)); + +export const NetlifyConnectionListItemSchema = z.object({ + name: z.literal("Netlify"), + app: z.literal(AppConnection.Netlify), + methods: z.nativeEnum(NetlifyConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/netlify/netlify-connection-service.ts b/backend/src/services/app-connection/netlify/netlify-connection-service.ts new file mode 100644 index 000000000..c2cf65e24 --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-service.ts @@ -0,0 +1,42 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { NetlifyPublicAPI } from "./netlify-connection-public-client"; +import { TNetlifyConnection } from "./netlify-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const netlifyConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listAccounts = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Netlify, connectionId, actor); + try { + const accounts = await NetlifyPublicAPI.getNetlifyAccounts(appConnection); + return accounts; + } catch (error) { + logger.error(error, "Failed to list accounts on Netlify"); + return []; + } + }; + + const listSites = async (connectionId: string, actor: OrgServiceActor, accountId: string) => { + const appConnection = await getAppConnection(AppConnection.Netlify, connectionId, actor); + try { + const sites = await NetlifyPublicAPI.getSites(appConnection, accountId); + return sites; + } catch (error) { + logger.error(error, "Failed to list sites on Netlify"); + return []; + } + }; + + return { + listAccounts, + listSites + }; +}; diff --git a/backend/src/services/app-connection/netlify/netlify-connection-types.ts b/backend/src/services/app-connection/netlify/netlify-connection-types.ts new file mode 100644 index 000000000..dc32f48d0 --- /dev/null +++ b/backend/src/services/app-connection/netlify/netlify-connection-types.ts @@ -0,0 +1,51 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateNetlifyConnectionSchema, + NetlifyConnectionSchema, + ValidateNetlifyConnectionCredentialsSchema +} from "./netlify-connection-schemas"; + +export type TNetlifyConnection = z.infer; + +export type TNetlifyConnectionInput = z.infer & { + app: AppConnection.Netlify; +}; + +export type TValidateNetlifyConnectionCredentialsSchema = typeof ValidateNetlifyConnectionCredentialsSchema; + +export type TNetlifyConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TNetlifyVariable = { + key: string; + id?: string; // ID of the variable (present in responses) + created_at?: string; + updated_at?: string; + is_secret?: boolean; + scopes?: ("builds" | "functions" | "runtime" | "post_processing")[]; + values: TNetlifyVariableValue[]; +}; + +export type TNetlifyVariableValue = { + id?: string; + context?: string; // "all", "dev", "branch-deploy", etc. + value?: string; // Omitted in response if `is_secret` is true + site_id?: string; // Optional: overrides at site-level + created_at?: string; + updated_at?: string; +}; + +export type TNetlifyAccount = { + id: string; + name: string; +}; + +export type TNetlifySite = { + id: string; + name: string; +}; diff --git a/backend/src/services/secret-sync/netlify/index.ts b/backend/src/services/secret-sync/netlify/index.ts new file mode 100644 index 000000000..2730a7757 --- /dev/null +++ b/backend/src/services/secret-sync/netlify/index.ts @@ -0,0 +1,4 @@ +export * from "./netlify-sync-constants"; +export * from "./netlify-sync-fns"; +export * from "./netlify-sync-schemas"; +export * from "./netlify-sync-types"; diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-constants.ts b/backend/src/services/secret-sync/netlify/netlify-sync-constants.ts new file mode 100644 index 000000000..c07f4c77a --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-constants.ts @@ -0,0 +1,19 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const NETLIFY_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Netlify", + destination: SecretSync.Netlify, + connection: AppConnection.Netlify, + canImportSecrets: true +}; + +export enum NetlifySyncContext { + All = "all", + DeployPreview = "deploy-preview", + Production = "production", + BranchDeploy = "branch-deploy", + Dev = "dev", + Branch = "branch" +} diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-fns.ts b/backend/src/services/secret-sync/netlify/netlify-sync-fns.ts new file mode 100644 index 000000000..23445623d --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-fns.ts @@ -0,0 +1,125 @@ +/* eslint-disable no-continue */ +import { NetlifyPublicAPI } from "@app/services/app-connection/netlify/netlify-connection-public-client"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { SecretSyncError } from "../secret-sync-errors"; +import type { TNetlifySyncWithCredentials } from "./netlify-sync-types"; + +export const NetlifySyncFns = { + async getSecrets(secretSync: TNetlifySyncWithCredentials): Promise { + const config = secretSync.destinationConfig; + + const params = { + account_id: config.accountId, + context_name: config.context, // Only used in the case of getVariables + site_id: config.siteId + }; + + const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params); + + const map = {} as TSecretMap; + + for (const variable of variables) { + if (variable.is_secret) continue; + + const latest = variable.values.sort((a, b) => Number(b.created_at) - Number(a.created_at))[0]; + + map[variable.key] = { + value: latest?.value ?? "" + }; + } + + return map; + }, + + async syncSecrets(secretSync: TNetlifySyncWithCredentials, secretMap: TSecretMap) { + const { + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + + const config = secretSync.destinationConfig; + + const params = { + account_id: config.accountId, + context_name: config.context, + site_id: config.siteId + }; + + const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params); + + const existing = Object.fromEntries(variables.map((variable) => [variable.key, variable])); + + for await (const key of Object.keys(secretMap)) { + try { + const entry = secretMap[key]; + + await NetlifyPublicAPI.upsertVariable(secretSync.connection, params, { + key, + is_secret: false, + values: [ + { + value: entry.value, + context: config.context + } + ] + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (disableSecretDeletion) return; + + for await (const key of Object.keys(existing)) { + try { + // eslint-disable-next-line no-continue, @typescript-eslint/no-unsafe-argument + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + await NetlifyPublicAPI.deleteVariable(secretSync.connection, params, { + key + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + }, + + async removeSecrets(secretSync: TNetlifySyncWithCredentials, secretMap: TSecretMap) { + const config = secretSync.destinationConfig; + + const params = { + account_id: config.accountId, + context_name: config.context, + site_id: config.siteId + }; + + const variables = await NetlifyPublicAPI.getVariables(secretSync.connection, params); + + const existingSecrets = Object.fromEntries(variables.map((variable) => [variable.key, variable])); + + for await (const secret of Object.keys(existingSecrets)) { + try { + if (secret in secretMap) { + await NetlifyPublicAPI.deleteVariable(secretSync.connection, params, { + key: secret + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: secret + }); + } + } + } +}; diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts b/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts new file mode 100644 index 000000000..cd9a985c9 --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts @@ -0,0 +1,68 @@ +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 { NetlifySyncContext } from "./netlify-sync-constants"; + +const NetlifySyncDestinationConfigSchema = z.object({ + accountId: z + .string() + .min(1, "Account ID is required") + .max(255, "Account ID must be less than 255 characters") + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.accountId), + accountName: z + .string() + .min(1, "Account Name is required") + .max(255, "Account Name must be less than 255 characters") + .optional() + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.accountName), + siteId: z + .string() + .min(1, "Site ID is required") + .max(255, "Site ID must be less than 255 characters") + .optional() + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.siteId), + siteName: z + .string() + .min(1, "Site Name is required") + .max(255, "Site Name must be less than 255 characters") + .optional() + .describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.siteName), + context: z.nativeEnum(NetlifySyncContext).optional().describe(SecretSyncs.DESTINATION_CONFIG.NETLIFY.context) +}); + +const NetlifySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const NetlifySyncSchema = BaseSecretSyncSchema(SecretSync.Netlify, NetlifySyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Netlify), + destinationConfig: NetlifySyncDestinationConfigSchema +}); + +export const CreateNetlifySyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Netlify, + NetlifySyncOptionsConfig +).extend({ + destinationConfig: NetlifySyncDestinationConfigSchema +}); + +export const UpdateNetlifySyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Netlify, + NetlifySyncOptionsConfig +).extend({ + destinationConfig: NetlifySyncDestinationConfigSchema.optional() +}); + +export const NetlifySyncListItemSchema = z.object({ + name: z.literal("Netlify"), + connection: z.literal(AppConnection.Netlify), + destination: z.literal(SecretSync.Netlify), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-types.ts b/backend/src/services/secret-sync/netlify/netlify-sync-types.ts new file mode 100644 index 000000000..2cef04686 --- /dev/null +++ b/backend/src/services/secret-sync/netlify/netlify-sync-types.ts @@ -0,0 +1,15 @@ +import z from "zod"; + +import { TNetlifyConnection } from "@app/services/app-connection/netlify"; + +import { CreateNetlifySyncSchema, NetlifySyncListItemSchema, NetlifySyncSchema } from "./netlify-sync-schemas"; + +export type TNetlifySyncListItem = z.infer; + +export type TNetlifySync = z.infer; + +export type TNetlifySyncInput = z.infer; + +export type TNetlifySyncWithCredentials = TNetlifySync & { + connection: TNetlifyConnection; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 55859200a..fe0dc9f56 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -27,6 +27,7 @@ export enum SecretSync { Railway = "railway", Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", + Netlify = "netlify", Bitbucket = "bitbucket" } diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 6fa46f1e6..3ff7cefbc 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -48,6 +48,7 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify"; import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; import { RailwaySyncFns } from "./railway/railway-sync-fns"; import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render"; @@ -88,6 +89,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Railway]: RAILWAY_SYNC_LIST_OPTION, [SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION, [SecretSync.DigitalOceanAppPlatform]: DIGITAL_OCEAN_APP_PLATFORM_SYNC_LIST_OPTION, + [SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION, [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION }; @@ -269,6 +271,8 @@ export const SecretSyncFns = { return SupabaseSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.DigitalOceanAppPlatform: return DigitalOceanAppPlatformSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Netlify: + return NetlifySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); default: @@ -381,6 +385,9 @@ export const SecretSyncFns = { case SecretSync.DigitalOceanAppPlatform: secretMap = await DigitalOceanAppPlatformSyncFns.getSecrets(secretSync); break; + case SecretSync.Netlify: + secretMap = await NetlifySyncFns.getSecrets(secretSync); + break; case SecretSync.Bitbucket: secretMap = await BitbucketSyncFns.getSecrets(secretSync); break; @@ -473,6 +480,8 @@ export const SecretSyncFns = { return SupabaseSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.DigitalOceanAppPlatform: return DigitalOceanAppPlatformSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Netlify: + return NetlifySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); default: diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 2f2b3c427..04e91051d 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -30,6 +30,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Railway]: "Railway", [SecretSync.Checkly]: "Checkly", [SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform", + [SecretSync.Netlify]: "Netlify", [SecretSync.Bitbucket]: "Bitbucket" }; @@ -62,6 +63,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Railway]: AppConnection.Railway, [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, + [SecretSync.Netlify]: AppConnection.Netlify, [SecretSync.Bitbucket]: AppConnection.Bitbucket }; @@ -94,5 +96,6 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Railway]: SecretSyncPlanType.Regular, [SecretSync.Checkly]: SecretSyncPlanType.Regular, [SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular, + [SecretSync.Netlify]: SecretSyncPlanType.Regular, [SecretSync.Bitbucket]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index a844fbe48..6435e19d3 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -117,6 +117,7 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { TNetlifySync, TNetlifySyncInput, TNetlifySyncListItem, TNetlifySyncWithCredentials } from "./netlify"; import { TRailwaySync, TRailwaySyncInput, @@ -178,6 +179,7 @@ export type TSecretSync = | TRailwaySync | TChecklySync | TSupabaseSync + | TNetlifySync | TBitbucketSync; export type TSecretSyncWithCredentials = @@ -209,6 +211,7 @@ export type TSecretSyncWithCredentials = | TChecklySyncWithCredentials | TSupabaseSyncWithCredentials | TDigitalOceanAppPlatformSyncWithCredentials + | TNetlifySyncWithCredentials | TBitbucketSyncWithCredentials; export type TSecretSyncInput = @@ -240,6 +243,7 @@ export type TSecretSyncInput = | TChecklySyncInput | TSupabaseSyncInput | TDigitalOceanAppPlatformSyncInput + | TNetlifySyncInput | TBitbucketSyncInput; export type TSecretSyncListItem = @@ -271,6 +275,7 @@ export type TSecretSyncListItem = | TChecklySyncListItem | TSupabaseSyncListItem | TDigitalOceanAppPlatformSyncListItem + | TNetlifySyncListItem | TBitbucketSyncListItem; export type TSyncOptionsConfig = { diff --git a/docs/api-reference/endpoints/app-connections/netlify/available.mdx b/docs/api-reference/endpoints/app-connections/netlify/available.mdx new file mode 100644 index 000000000..19ca363cc --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/netlify/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/create.mdx b/docs/api-reference/endpoints/app-connections/netlify/create.mdx new file mode 100644 index 000000000..8c99c14e7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/netlify" +--- + + + Check out the configuration docs for [Netlify Connections](/integrations/app-connections/netlify) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/netlify/delete.mdx b/docs/api-reference/endpoints/app-connections/netlify/delete.mdx new file mode 100644 index 000000000..8ab1741a8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/netlify/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/netlify/get-by-id.mdx new file mode 100644 index 000000000..cfc49a128 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/netlify/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/netlify/get-by-name.mdx new file mode 100644 index 000000000..5869b5d23 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/netlify/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/list.mdx b/docs/api-reference/endpoints/app-connections/netlify/list.mdx new file mode 100644 index 000000000..50de77482 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/netlify" +--- diff --git a/docs/api-reference/endpoints/app-connections/netlify/update.mdx b/docs/api-reference/endpoints/app-connections/netlify/update.mdx new file mode 100644 index 000000000..f3a981095 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/netlify/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/netlify/{connectionId}" +--- + + + Check out the configuration docs for [Netlify Connections](/integrations/app-connections/netlify) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/create.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/create.mdx new file mode 100644 index 000000000..6fd4a4391 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/netlify" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/delete.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/delete.mdx new file mode 100644 index 000000000..051d6825c --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/netlify/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-id.mdx new file mode 100644 index 000000000..d207e29a6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/netlify/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-name.mdx new file mode 100644 index 000000000..caee424b2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/netlify/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/import-secrets.mdx new file mode 100644 index 000000000..41fdadf4f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/list.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/list.mdx new file mode 100644 index 000000000..e366d82e9 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/netlify" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/remove-secrets.mdx new file mode 100644 index 000000000..5d402e061 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/sync-secrets.mdx new file mode 100644 index 000000000..437bc8524 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/netlify/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/netlify/update.mdx b/docs/api-reference/endpoints/secret-syncs/netlify/update.mdx new file mode 100644 index 000000000..e51ba3270 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/netlify/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/netlify/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index 6729db05a..4459a67de 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -497,6 +497,7 @@ "integrations/app-connections/ldap", "integrations/app-connections/mssql", "integrations/app-connections/mysql", + "integrations/app-connections/netlify", "integrations/app-connections/oci", "integrations/app-connections/okta", "integrations/app-connections/oracledb", @@ -540,6 +541,7 @@ "integrations/secret-syncs/hashicorp-vault", "integrations/secret-syncs/heroku", "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/netlify", "integrations/secret-syncs/oci-vault", "integrations/secret-syncs/railway", "integrations/secret-syncs/render", @@ -1557,6 +1559,18 @@ "api-reference/endpoints/app-connections/mysql/update", "api-reference/endpoints/app-connections/mysql/delete" ] + }, + { + "group": "Netlify", + "pages": [ + "api-reference/endpoints/app-connections/netlify/list", + "api-reference/endpoints/app-connections/netlify/available", + "api-reference/endpoints/app-connections/netlify/get-by-id", + "api-reference/endpoints/app-connections/netlify/get-by-name", + "api-reference/endpoints/app-connections/netlify/create", + "api-reference/endpoints/app-connections/netlify/update", + "api-reference/endpoints/app-connections/netlify/delete" + ] }, { "group": "OCI", @@ -1978,6 +1992,19 @@ "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] }, + { + "group": "Netlify", + "pages": [ + "api-reference/endpoints/secret-syncs/netlify/list", + "api-reference/endpoints/secret-syncs/netlify/get-by-id", + "api-reference/endpoints/secret-syncs/netlify/get-by-name", + "api-reference/endpoints/secret-syncs/netlify/create", + "api-reference/endpoints/secret-syncs/netlify/update", + "api-reference/endpoints/secret-syncs/netlify/delete", + "api-reference/endpoints/secret-syncs/netlify/sync-secrets", + "api-reference/endpoints/secret-syncs/netlify/remove-secrets" + ] + }, { "group": "OCI", "pages": [ diff --git a/docs/images/app-connections/netlify/app-connection-create-api-key.png b/docs/images/app-connections/netlify/app-connection-create-api-key.png new file mode 100644 index 000000000..b7832db9e Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-create-api-key.png differ diff --git a/docs/images/app-connections/netlify/app-connection-create-form.png b/docs/images/app-connections/netlify/app-connection-create-form.png new file mode 100644 index 000000000..9435e3f94 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-create-form.png differ diff --git a/docs/images/app-connections/netlify/app-connection-dashboard.png b/docs/images/app-connections/netlify/app-connection-dashboard.png new file mode 100644 index 000000000..aca7ccdd8 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-dashboard.png differ diff --git a/docs/images/app-connections/netlify/app-connection-form.png b/docs/images/app-connections/netlify/app-connection-form.png new file mode 100644 index 000000000..13ebbdd42 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-form.png differ diff --git a/docs/images/app-connections/netlify/app-connection-generated.png b/docs/images/app-connections/netlify/app-connection-generated.png new file mode 100644 index 000000000..67433626a Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-generated.png differ diff --git a/docs/images/app-connections/netlify/app-connection-key-generated.png b/docs/images/app-connections/netlify/app-connection-key-generated.png new file mode 100644 index 000000000..9c3aaf78b Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-key-generated.png differ diff --git a/docs/images/app-connections/netlify/app-connection-option.png b/docs/images/app-connections/netlify/app-connection-option.png new file mode 100644 index 000000000..5e9782a8c Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-option.png differ diff --git a/docs/images/app-connections/netlify/app-connection-profile.png b/docs/images/app-connections/netlify/app-connection-profile.png new file mode 100644 index 000000000..eeda6a5a8 Binary files /dev/null and b/docs/images/app-connections/netlify/app-connection-profile.png differ diff --git a/docs/images/secret-syncs/netlify/select-option.png b/docs/images/secret-syncs/netlify/select-option.png new file mode 100644 index 000000000..ee18232fc Binary files /dev/null and b/docs/images/secret-syncs/netlify/select-option.png differ diff --git a/docs/images/secret-syncs/netlify/sync-created.png b/docs/images/secret-syncs/netlify/sync-created.png new file mode 100644 index 000000000..dff4f2c98 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-created.png differ diff --git a/docs/images/secret-syncs/netlify/sync-destination.png b/docs/images/secret-syncs/netlify/sync-destination.png new file mode 100644 index 000000000..055ca0f69 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-destination.png differ diff --git a/docs/images/secret-syncs/netlify/sync-details.png b/docs/images/secret-syncs/netlify/sync-details.png new file mode 100644 index 000000000..82c7fac8b Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-details.png differ diff --git a/docs/images/secret-syncs/netlify/sync-options.png b/docs/images/secret-syncs/netlify/sync-options.png new file mode 100644 index 000000000..d91e843df Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-options.png differ diff --git a/docs/images/secret-syncs/netlify/sync-review.png b/docs/images/secret-syncs/netlify/sync-review.png new file mode 100644 index 000000000..3795526f8 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-review.png differ diff --git a/docs/images/secret-syncs/netlify/sync-source.png b/docs/images/secret-syncs/netlify/sync-source.png new file mode 100644 index 000000000..9f01e3475 Binary files /dev/null and b/docs/images/secret-syncs/netlify/sync-source.png differ diff --git a/docs/integrations/app-connections/netlify.mdx b/docs/integrations/app-connections/netlify.mdx new file mode 100644 index 000000000..cd4dfb1b5 --- /dev/null +++ b/docs/integrations/app-connections/netlify.mdx @@ -0,0 +1,102 @@ +--- +title: "Netlify Connection" +description: "Learn how to configure a Netlify Connection for Infisical." +--- + +Infisical supports the use of [Personal Access Tokens](https://docs.netlify.com/api/get-started/#get-access-tokens) to connect with Netlify. + + Netlify requires the token to have **full access** to enable secret management for your sites and services. + + +## Create a Netlify Personal Access Token + + + + ![Netlify User Settings](/images/app-connections/netlify/app-connection-profile.png) + + + ![Applications Tab](/images/app-connections/netlify/app-connection-create-api-key.png) + + + Provide a name for your token and generate it. + + ![Token Form](/images/app-connections/netlify/app-connection-create-form.png) + + + Make sure to copy the token now—you won’t be able to access it again. + + ![Token Generated](/images/app-connections/netlify/app-connection-key-generated.png) + + + +## Create a Netlify Connection in Infisical + + + + + + In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Netlify Connection** from the list of integrations. + + ![Select Netlify Connection](/images/app-connections/netlify/app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The API Token from the previous step + + ![Netlify Connection Modal](/images/app-connections/netlify/app-connection-form.png) + + + After submitting the form, your **Netlify Connection** will be successfully created and ready to use with your Infisical projects. + + ![Netlify Connection Created](/images/app-connections/netlify/app-connection-generated.png) + + + + + + To create a Netlify Connection via API, send a request to the [Create Netlify Connection](/api-reference/endpoints/app-connections/netlify/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/netlify \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-netlify-connection", + "method": "access-token", + "credentials": { + "accessToken": "[ACCESS TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "name": "my-netlify-connection", + "description": null, + "version": 1, + "orgId": "abcdef12-3456-7890-abcd-ef1234567890", + "createdAt": "2025-07-19T10:15:00.000Z", + "updatedAt": "2025-07-19T10:15:00.000Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "d41d8cd98f00b204e9800998ecf8427e", + "app": "netlify", + "method": "access-token", + "credentials": {} + } + } + ``` + + diff --git a/docs/integrations/secret-syncs/netlify.mdx b/docs/integrations/secret-syncs/netlify.mdx new file mode 100644 index 000000000..fd98fac41 --- /dev/null +++ b/docs/integrations/secret-syncs/netlify.mdx @@ -0,0 +1,161 @@ +--- +title: "Netlify Sync" +description: "Learn how to configure a Netlify Sync for Infisical." +--- + +**Prerequisites:** + +- Create a [Netlify Connection](/integrations/app-connections/netlify) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Netlify](/images/secret-syncs/netlify/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/netlify/sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/netlify/sync-destination.png) + + - **Netlify Connection**: The Netlify Connection to authenticate with. + - **Account**: The Netlify Account to be used. + - **Site**: The Netlify site where secrets should be synced. + - **Context**: The Netlify deployment context where secrets should be created in. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/netlify/sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Netlify when keys conflict. + - **Import Secrets (Prioritize Netlify)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Netlify over Infisical when keys conflict. + + Netlify only supports importing non-secret values. + + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Netlify Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/netlify/sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Netlify Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/netlify/sync-review.png) + + + If enabled, your Netlify Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/netlify/sync-created.png) + + + + + + To create a **Netlify Sync**, make an API request to the [Create Netlify Sync](/api-reference/endpoints/secret-syncs/netlify/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/netlify \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-netlify-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "sync to netlify site", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "production", + "secretPath": "/site-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "destinationConfig": { + "siteId": "site-abc123", + "accountId": "account-abc123", + "context": "dev" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-netlify-sync", + "description": "sync to netlify site", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-07-19T12:00:00Z", + "updatedAt": "2025-07-19T12:00:00Z", + "syncStatus": "succeeded", + "lastSyncJobId": "job-1234", + "lastSyncMessage": null, + "lastSyncedAt": "2025-07-19T12:00:00Z", + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "autoSyncEnabled": true, + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "netlify", + "name": "my-netlify-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "production", + "name": "Production", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/site-secrets" + }, + "destination": "netlify", + "destinationConfig": { + "siteId": "site-abc123", + "accountId": "account-abc123", + "context": "dev" + } + } + } + ``` + + diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx index 25d992735..dc2ab385a 100644 --- a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -31,7 +31,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => environment: secretSync.environment ?? undefined, secretPath: secretSync.folder?.path, description: secretSync.description ?? "" - }, + } as Partial, reValidateMode: "onChange" }); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NetlifySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NetlifySyncFields.tsx new file mode 100644 index 000000000..fd977ec2a --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NetlifySyncFields.tsx @@ -0,0 +1,144 @@ +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 { + TNetlifyAccount, + TNetlifySite, + useNetlifyConnectionListAccounts, + useNetlifyConnectionListSites +} from "@app/hooks/api/appConnections/netlify"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; +import { NetlifySyncContext } from "../schemas/netlify-sync-destination-schema"; + +export const NetlifySyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Netlify } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const accountId = useWatch({ name: "destinationConfig.accountId", control }); + + const { data: accounts = [], isPending: isAccountsLoading } = useNetlifyConnectionListAccounts( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const { data: sites = [], isPending: isSitesLoading } = useNetlifyConnectionListSites( + connectionId, + accountId, + { + enabled: Boolean(accountId) + } + ); + + const contexts = Object.entries(NetlifySyncContext).map(([key, value]) => ({ + label: key, + value + })); + + return ( + <> + { + setValue("destinationConfig.accountId", ""); + setValue("destinationConfig.accountName", ""); + }} + /> + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.accountName", v?.name ?? ""); + }} + options={accounts} + placeholder="Select an account..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + if (v?.id === value) { + onChange(undefined); + setValue("destinationConfig.siteName", undefined); + } else { + onChange(v?.id); + setValue("destinationConfig.siteName", v?.name); + } + }} + options={sites} + placeholder="Select a site..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + ( + + p.value === value) ?? undefined} + onChange={(option) => { + const v = option as SingleValue<{ label: string; value: NetlifySyncContext }>; + if (v) onChange(v.value); + }} + options={contexts} + placeholder="Select a context..." + getOptionLabel={(option) => option.label} + getOptionValue={(option) => option.value} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 478447541..b2186fed1 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -23,6 +23,7 @@ import { GitLabSyncFields } from "./GitLabSyncFields"; import { HCVaultSyncFields } from "./HCVaultSyncFields"; import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { NetlifySyncFields } from "./NetlifySyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; import { RailwaySyncFields } from "./RailwaySyncFields"; import { RenderSyncFields } from "./RenderSyncFields"; @@ -95,6 +96,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.DigitalOceanAppPlatform: return ; + case SecretSync.Netlify: + return ; case SecretSync.Bitbucket: return ; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index cb7a40559..93e71cfc6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -67,6 +67,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Checkly: case SecretSync.Supabase: case SecretSync.DigitalOceanAppPlatform: + case SecretSync.Netlify: case SecretSync.Bitbucket: AdditionalSyncOptionsFieldsComponent = null; break; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NetlifySyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NetlifySyncReviewFields.tsx new file mode 100644 index 000000000..5898de7c3 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NetlifySyncReviewFields.tsx @@ -0,0 +1,22 @@ +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 NetlifySyncReviewFields = () => { + const { watch } = useFormContext(); + const accountName = watch("destinationConfig.accountName"); + const accountId = watch("destinationConfig.accountId"); + const siteName = watch("destinationConfig.siteName"); + const siteId = watch("destinationConfig.siteId"); + const context = watch("destinationConfig.context"); + + return ( + <> + {accountName || accountId} + {siteName || siteId || "None"} + {context || "None"} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 5ee53f2e3..eaeab2e66 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -32,6 +32,7 @@ import { GitLabSyncReviewFields } from "./GitLabSyncReviewFields"; import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { NetlifySyncReviewFields } from "./NetlifySyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; import { RailwaySyncReviewFields } from "./RailwaySyncReviewFields"; @@ -150,6 +151,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.DigitalOceanAppPlatform: DestinationFieldsComponent = ; break; + case SecretSync.Netlify: + DestinationFieldsComponent = ; + break; case SecretSync.Bitbucket: DestinationFieldsComponent = ; break; diff --git a/frontend/src/components/secret-syncs/forms/schemas/netlify-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/netlify-sync-destination-schema.ts new file mode 100644 index 000000000..b0412bda1 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/netlify-sync-destination-schema.ts @@ -0,0 +1,26 @@ +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 enum NetlifySyncContext { + // All = "all", - Netlify doesn't allow "all" as the context when using is_secret: true + DeployPreview = "deploy-preview", + Production = "production", + BranchDeploy = "branch-deploy", + Dev = "dev", + Branch = "branch" +} + +export const NetlifySyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Netlify), + destinationConfig: z.object({ + accountId: z.string(), + accountName: z.string(), + siteId: z.string().optional(), + siteName: z.string().optional(), + context: z.nativeEnum(NetlifySyncContext).optional() + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 5ffd5940a..f4616c711 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -20,6 +20,7 @@ import { GitlabSyncDestinationSchema } from "./gitlab-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 { NetlifySyncDestinationSchema } from "./netlify-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema"; import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; @@ -59,6 +60,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ RailwaySyncDestinationSchema, ChecklySyncDestinationSchema, DigitalOceanAppPlatformSyncDestinationSchema, + NetlifySyncDestinationSchema, BitbucketSyncDestinationSchema ]); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 61c19ad9a..7ecc92811 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -45,6 +45,7 @@ import { BitbucketConnectionMethod } from "@app/hooks/api/appConnections/types/b import { ChecklyConnectionMethod } from "@app/hooks/api/appConnections/types/checkly-connection"; import { DigitalOceanConnectionMethod } from "@app/hooks/api/appConnections/types/digital-ocean"; import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; +import { NetlifyConnectionMethod } from "@app/hooks/api/appConnections/types/netlify-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection"; import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; @@ -105,6 +106,10 @@ export const APP_CONNECTION_MAP: Record< name: "Digital Ocean", image: "Digital Ocean.png" }, + [AppConnection.Netlify]: { + name: "Netlify", + image: "Netlify.png" + }, [AppConnection.Okta]: { name: "Okta", image: "Okta.png" } }; @@ -152,6 +157,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case AzureDevOpsConnectionMethod.AccessToken: case WindmillConnectionMethod.AccessToken: case FlyioConnectionMethod.AccessToken: + case NetlifyConnectionMethod.AccessToken: return { name: "Access Token", icon: faKey }; case Auth0ConnectionMethod.ClientCredentials: return { name: "Client Credentials", icon: faServer }; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 63eb0d0f6..8da61583e 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -106,6 +106,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Railway]: AppConnection.Railway, [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, + [SecretSync.Netlify]: AppConnection.Netlify, [SecretSync.Bitbucket]: AppConnection.Bitbucket }; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 6969d55bd..eff3c9b62 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -34,5 +34,6 @@ export enum AppConnection { Checkly = "checkly", Supabase = "supabase", DigitalOcean = "digital-ocean", + Netlify = "netlify", Okta = "okta" } diff --git a/frontend/src/hooks/api/appConnections/netlify/index.ts b/frontend/src/hooks/api/appConnections/netlify/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/netlify/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/netlify/queries.tsx b/frontend/src/hooks/api/appConnections/netlify/queries.tsx new file mode 100644 index 000000000..d196a69b5 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/netlify/queries.tsx @@ -0,0 +1,65 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TNetlifyAccount, TNetlifySite } from "./types"; + +const netlifyConnectionKeys = { + all: [...appConnectionKeys.all, "netlify"] as const, + listAccounts: (connectionId: string) => + [...netlifyConnectionKeys.all, "accounts", connectionId] as const, + listSites: (connectionId: string, accountId: string) => + [...netlifyConnectionKeys.all, "sites", connectionId, accountId] as const +}; + +export const useNetlifyConnectionListAccounts = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TNetlifyAccount[], + unknown, + TNetlifyAccount[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: netlifyConnectionKeys.listAccounts(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ accounts: TNetlifyAccount[] }>( + `/api/v1/app-connections/netlify/${connectionId}/accounts` + ); + + return data.accounts; + }, + ...options + }); +}; + +export const useNetlifyConnectionListSites = ( + connectionId: string, + accountId: string, + options?: Omit< + UseQueryOptions< + TNetlifySite[], + unknown, + TNetlifySite[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: netlifyConnectionKeys.listSites(connectionId, accountId), + queryFn: async () => { + const { data } = await apiRequest.get<{ sites: TNetlifySite[] }>( + `/api/v1/app-connections/netlify/${connectionId}/accounts/${accountId}/sites` + ); + + return data.sites; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/netlify/types.ts b/frontend/src/hooks/api/appConnections/netlify/types.ts new file mode 100644 index 000000000..aa514e41b --- /dev/null +++ b/frontend/src/hooks/api/appConnections/netlify/types.ts @@ -0,0 +1,9 @@ +export type TNetlifyAccount = { + id: string; + name: string; +}; + +export type TNetlifySite = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 3c2e02af4..4191cb890 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -156,6 +156,10 @@ export type TDigitalOceanConnectionOption = TAppConnectionOptionBase & { app: AppConnection.DigitalOcean; }; +export type TNetlifyConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Netlify; +}; + export type TOktaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Okta; }; @@ -193,6 +197,7 @@ export type TAppConnectionOption = | TRailwayConnectionOption | TChecklyConnectionOption | TDigitalOceanConnectionOption + | TNetlifyConnectionOption | TOktaConnectionOption; export type TAppConnectionOptionMap = { @@ -231,5 +236,6 @@ export type TAppConnectionOptionMap = { [AppConnection.Checkly]: TChecklyConnectionOption; [AppConnection.Supabase]: TSupabaseConnectionOption; [AppConnection.DigitalOcean]: TDigitalOceanConnectionOption; + [AppConnection.Netlify]: TNetlifyConnectionOption; [AppConnection.Okta]: TOktaConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index c9e2d6745..f07dcd283 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -24,6 +24,7 @@ import { THumanitecConnection } from "./humanitec-connection"; import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TMySqlConnection } from "./mysql-connection"; +import { TNetlifyConnection } from "./netlify-connection"; import { TOCIConnection } from "./oci-connection"; import { TOktaConnection } from "./okta-connection"; import { TOracleDBConnection } from "./oracledb-connection"; @@ -109,6 +110,7 @@ export type TAppConnection = | TChecklyConnection | TSupabaseConnection | TDigitalOceanConnection + | TNetlifyConnection | TOktaConnection; export type TAvailableAppConnection = Pick; @@ -181,5 +183,6 @@ export type TAppConnectionMap = { [AppConnection.Checkly]: TChecklyConnection; [AppConnection.Supabase]: TSupabaseConnection; [AppConnection.DigitalOcean]: TDigitalOceanConnection; + [AppConnection.Netlify]: TNetlifyConnection; [AppConnection.Okta]: TOktaConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/netlify-connection.ts b/frontend/src/hooks/api/appConnections/types/netlify-connection.ts new file mode 100644 index 000000000..567664d96 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/netlify-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum NetlifyConnectionMethod { + AccessToken = "access-token" +} + +export type TNetlifyConnection = TRootAppConnection & { + app: AppConnection.Netlify; + method: NetlifyConnectionMethod.AccessToken; + credentials: { + accessToken: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 11de8cd62..c230cd9c6 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -27,6 +27,7 @@ export enum SecretSync { Railway = "railway", Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", + Netlify = "netlify", Bitbucket = "bitbucket" } diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 2ab76341b..f7acda1f4 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -21,6 +21,7 @@ import { TGitLabSync } from "./gitlab-sync"; import { THCVaultSync } from "./hc-vault-sync"; import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TNetlifySync } from "./netlify-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TRailwaySync } from "./railway-sync"; import { TRenderSync } from "./render-sync"; @@ -67,6 +68,7 @@ export type TSecretSync = | TChecklySync | TSupabaseSync | TDigitalOceanAppPlatformSync + | TNetlifySync | TBitbucketSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/netlify-sync.ts b/frontend/src/hooks/api/secretSyncs/types/netlify-sync.ts new file mode 100644 index 000000000..1163224ef --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/netlify-sync.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ +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 type TNetlifySync = TRootSecretSync & { + destination: SecretSync.Netlify; + destinationConfig: { + accountId: string; + accountName: string; + siteId?: string; + siteName?: string; + context?: string; + }; + connection: { + app: AppConnection.Netlify; + name: string; + id: string; + }; +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 3a5e78c1a..860a0cb6b 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -33,6 +33,7 @@ import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; import { LdapConnectionForm } from "./LdapConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { MySqlConnectionForm } from "./MySqlConnectionForm"; +import { NetlifyConnectionForm } from "./NetlifyConnectionForm"; import { OCIConnectionForm } from "./OCIConnectionForm"; import { OktaConnectionForm } from "./OktaConnectionForm"; import { OracleDBConnectionForm } from "./OracleDBConnectionForm"; @@ -153,6 +154,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.DigitalOcean: return ; + case AppConnection.Netlify: + return ; case AppConnection.Okta: return ; default: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/NetlifyConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/NetlifyConnectionForm.tsx new file mode 100644 index 000000000..706560808 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/NetlifyConnectionForm.tsx @@ -0,0 +1,136 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + NetlifyConnectionMethod, + TNetlifyConnection +} from "@app/hooks/api/appConnections/types/netlify-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TNetlifyConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Netlify) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(NetlifyConnectionMethod.AccessToken), + credentials: z.object({ + accessToken: z.string().trim().min(1, "Netlify Access Token required") + }) + }) +]); + +type FormData = z.infer; + +export const NetlifyConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Netlify, + method: NetlifyConnectionMethod.AccessToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> + +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/NetlifySyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/NetlifySyncDestinationCol.tsx new file mode 100644 index 000000000..ba5c15d9c --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/NetlifySyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TNetlifySync } from "@app/hooks/api/secretSyncs/types/netlify-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TNetlifySync; +}; + +export const NetlifySyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index f6848f748..009af9930 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -20,6 +20,7 @@ import { GitLabSyncDestinationCol } from "./GitLabSyncDestinationCol"; import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol"; import { HerokuSyncDestinationCol } from "./HerokuSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; +import { NetlifySyncDestinationCol } from "./NetlifySyncDestinationCol"; import { OCIVaultSyncDestinationCol } from "./OCIVaultSyncDestinationCol"; import { RailwaySyncDestinationCol } from "./RailwaySyncDestinationCol"; import { RenderSyncDestinationCol } from "./RenderSyncDestinationCol"; @@ -92,6 +93,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.DigitalOceanAppPlatform: return ; + case SecretSync.Netlify: + return ; case SecretSync.Bitbucket: return ; default: diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 1ee3b141e..9b88ba447 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -178,6 +178,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.appName; secondaryText = "Digital Ocean App"; break; + case SecretSync.Netlify: + primaryText = destinationConfig.siteName || destinationConfig.siteId || "All Sites"; + secondaryText = destinationConfig.accountName || destinationConfig.accountId; + break; case SecretSync.Bitbucket: primaryText = destinationConfig.workspaceSlug; secondaryText = destinationConfig.repositorySlug; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/NetlifySyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/NetlifySyncDestinationSection.tsx new file mode 100644 index 000000000..40981361b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/NetlifySyncDestinationSection.tsx @@ -0,0 +1,22 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TNetlifySync } from "@app/hooks/api/secretSyncs/types/netlify-sync"; + +type Props = { + secretSync: TNetlifySync; +}; + +export const NetlifySyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + return ( + <> + + {destinationConfig.accountName || destinationConfig.accountId} + + + {destinationConfig.siteName || destinationConfig.siteId || "None"} + + {destinationConfig.context || "None"} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index 6213b264d..2b800fc6d 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -31,6 +31,7 @@ import { GitLabSyncDestinationSection } from "./GitLabSyncDestinationSection"; import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection"; import { HerokuSyncDestinationSection } from "./HerokuSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; +import { NetlifySyncDestinationSection } from "./NetlifySyncDestinationSection"; import { OCIVaultSyncDestinationSection } from "./OCIVaultSyncDestinationSection"; import { RailwaySyncDestinationSection } from "./RailwaySyncDestinationSection"; import { RenderSyncDestinationSection } from "./RenderSyncDestinationSection"; @@ -141,6 +142,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: ); break; + case SecretSync.Netlify: + DestinationComponents = ; + break; case SecretSync.Bitbucket: DestinationComponents = ; break; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index 32c46fca2..0d0e19cac 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -69,6 +69,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.Supabase: case SecretSync.Checkly: case SecretSync.DigitalOceanAppPlatform: + case SecretSync.Netlify: case SecretSync.Bitbucket: AdditionalSyncOptionsComponent = null; break;