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 53c503e4a..7ca43154f 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 @@ -60,6 +60,10 @@ import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema } from "@app/services/app-connection/postgres"; +import { + RenderConnectionListItemSchema, + SanitizedRenderConnectionSchema +} from "@app/services/app-connection/render/render-connection-schema"; import { SanitizedTeamCityConnectionSchema, TeamCityConnectionListItemSchema @@ -100,7 +104,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedTeamCityConnectionSchema.options, ...SanitizedOCIConnectionSchema.options, ...SanitizedOracleDBConnectionSchema.options, - ...SanitizedOnePassConnectionSchema.options + ...SanitizedOnePassConnectionSchema.options, + ...SanitizedRenderConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -127,7 +132,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ TeamCityConnectionListItemSchema, OCIConnectionListItemSchema, OracleDBConnectionListItemSchema, - OnePassConnectionListItemSchema + OnePassConnectionListItemSchema, + RenderConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { 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 5f07f1be7..c5dcec537 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -20,6 +20,7 @@ import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; +import { registerRenderConnectionRouter } from "./render-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; import { registerVercelConnectionRouter } from "./vercel-connection-router"; @@ -52,5 +53,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Render, + server, + sanitizedResponseSchema: SanitizedRenderConnectionSchema, + createSchema: CreateRenderConnectionSchema, + updateSchema: UpdateRenderConnectionSchema + }); +}; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 19aabc55e..9c0115b55 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -22,7 +22,8 @@ export enum AppConnection { TeamCity = "teamcity", OCI = "oci", OracleDB = "oracledb", - OnePass = "1password" + OnePass = "1password", + Render = "render" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 4a272bb5c..d022e1647 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -78,6 +78,8 @@ import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; +import { RenderConnectionMethod } from "./render/render-connection-enums"; +import { getRenderConnectionListItem, validateRenderConnectionCredentials } from "./render/render-connection-fns"; import { getTeamCityConnectionListItem, TeamCityConnectionMethod, @@ -121,7 +123,8 @@ export const listAppConnectionOptions = () => { getTeamCityConnectionListItem(), getOCIConnectionListItem(), getOracleDBConnectionListItem(), - getOnePassConnectionListItem() + getOnePassConnectionListItem(), + getRenderConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -196,7 +199,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.OracleDB]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -245,6 +249,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "App Role"; case LdapConnectionMethod.SimpleBind: return "Simple Bind"; + case RenderConnectionMethod.ApiKey: + return "API Key"; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection Method: ${method}`); @@ -299,7 +305,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.TeamCity]: platformManagedCredentialsNotSupported, [AppConnection.OCI]: platformManagedCredentialsNotSupported, [AppConnection.OracleDB]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, - [AppConnection.OnePass]: platformManagedCredentialsNotSupported + [AppConnection.OnePass]: platformManagedCredentialsNotSupported, + [AppConnection.Render]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 2ed97c8c5..2e379a3bd 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -62,6 +62,7 @@ import { ValidateLdapConnectionCredentialsSchema } from "./ldap"; import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; import { ValidateMySqlConnectionCredentialsSchema } from "./mysql"; import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; +import { ValidateRenderConnectionCredentialsSchema } from "./render/render-connection-schema"; import { ValidateTeamCityConnectionCredentialsSchema } from "./teamcity"; import { teamcityConnectionService } from "./teamcity/teamcity-connection-service"; import { ValidateTerraformCloudConnectionCredentialsSchema } from "./terraform-cloud"; @@ -104,7 +105,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record>>; @@ -192,6 +199,7 @@ export type TAppConnectionInput = { id: string } & ( | TOCIConnectionInput | TOracleDBConnectionInput | TOnePassConnectionInput + | TRenderConnectionInput ); export type TSqlConnectionInput = @@ -230,7 +238,8 @@ export type TAppConnectionConfig = | TLdapConnectionConfig | TTeamCityConnectionConfig | TOCIConnectionConfig - | TOnePassConnectionConfig; + | TOnePassConnectionConfig + | TRenderConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -256,7 +265,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateTeamCityConnectionCredentialsSchema | TValidateOCIConnectionCredentialsSchema | TValidateOracleDBConnectionCredentialsSchema - | TValidateOnePassConnectionCredentialsSchema; + | TValidateOnePassConnectionCredentialsSchema + | TValidateRenderConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/render/render-connection-enums.ts b/backend/src/services/app-connection/render/render-connection-enums.ts new file mode 100644 index 000000000..36ca11384 --- /dev/null +++ b/backend/src/services/app-connection/render/render-connection-enums.ts @@ -0,0 +1,3 @@ +export enum RenderConnectionMethod { + ApiKey = "api-key" +} diff --git a/backend/src/services/app-connection/render/render-connection-fns.ts b/backend/src/services/app-connection/render/render-connection-fns.ts new file mode 100644 index 000000000..5792cec51 --- /dev/null +++ b/backend/src/services/app-connection/render/render-connection-fns.ts @@ -0,0 +1,40 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { AppConnection } from "../app-connection-enums"; +import { RenderConnectionMethod } from "./render-connection-enums"; +import { TRenderConnectionConfig } from "./render-connection-types"; + +export const getRenderConnectionListItem = () => { + return { + name: "Render" as const, + app: AppConnection.Render as const, + methods: Object.values(RenderConnectionMethod) as [RenderConnectionMethod.ApiKey] + }; +}; + +export const validateRenderConnectionCredentials = async (config: TRenderConnectionConfig) => { + const { credentials: inputCredentials } = config; + + try { + await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/users`, { + headers: { + Authorization: `Bearer ${inputCredentials.apiKey}` + } + }); + } 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 inputCredentials; +}; diff --git a/backend/src/services/app-connection/render/render-connection-schema.ts b/backend/src/services/app-connection/render/render-connection-schema.ts new file mode 100644 index 000000000..6224b471e --- /dev/null +++ b/backend/src/services/app-connection/render/render-connection-schema.ts @@ -0,0 +1,56 @@ +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 { RenderConnectionMethod } from "./render-connection-enums"; + +export const RenderConnectionApiKeyCredentialsSchema = z.object({ + apiKey: z.string().trim().min(1, "API key required") +}); + +const BaseRenderConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Render) }); + +export const RenderConnectionSchema = BaseRenderConnectionSchema.extend({ + method: z.literal(RenderConnectionMethod.ApiKey), + credentials: RenderConnectionApiKeyCredentialsSchema +}); + +export const SanitizedRenderConnectionSchema = z.discriminatedUnion("method", [ + BaseRenderConnectionSchema.extend({ + method: z.literal(RenderConnectionMethod.ApiKey), + credentials: RenderConnectionApiKeyCredentialsSchema.pick({}) + }) +]); + +export const ValidateRenderConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(RenderConnectionMethod.ApiKey).describe(AppConnections.CREATE(AppConnection.Render).method), + credentials: RenderConnectionApiKeyCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Render).credentials + ) + }) +]); + +export const CreateRenderConnectionSchema = ValidateRenderConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Render) +); + +export const UpdateRenderConnectionSchema = z + .object({ + credentials: RenderConnectionApiKeyCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Render).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Render)); + +export const RenderConnectionListItemSchema = z.object({ + name: z.literal("Render"), + app: z.literal(AppConnection.Render), + methods: z.nativeEnum(RenderConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/render/render-connection-types.ts b/backend/src/services/app-connection/render/render-connection-types.ts new file mode 100644 index 000000000..025259904 --- /dev/null +++ b/backend/src/services/app-connection/render/render-connection-types.ts @@ -0,0 +1,22 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateRenderConnectionSchema, + RenderConnectionSchema, + ValidateRenderConnectionCredentialsSchema +} from "./render-connection-schema"; + +export type TRenderConnection = z.infer; + +export type TRenderConnectionInput = z.infer & { + app: AppConnection.Render; +}; + +export type TValidateRenderConnectionCredentialsSchema = typeof ValidateRenderConnectionCredentialsSchema; + +export type TRenderConnectionConfig = DiscriminativePick & { + orgId: string; +}; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 7a87de58c..cbc9de156 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -37,6 +37,7 @@ import { WindmillConnectionMethod } from "@app/hooks/api/appConnections/types"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; +import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; export const APP_CONNECTION_MAP: Record< AppConnection, @@ -78,7 +79,8 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.LDAP]: { name: "LDAP", image: "LDAP.png", size: 65 }, [AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" }, [AppConnection.OCI]: { name: "OCI", image: "Oracle.png", enterprise: true }, - [AppConnection.OnePass]: { name: "1Password", image: "1Password.png" } + [AppConnection.OnePass]: { name: "1Password", image: "1Password.png" }, + [AppConnection.Render]: { name: "Render", image: "Render.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -124,6 +126,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "App Role", icon: faUser }; case LdapConnectionMethod.SimpleBind: return { name: "Simple Bind", icon: faLink }; + case RenderConnectionMethod.ApiKey: + return { name: "API Key", icon: faKey }; default: throw new Error(`Unhandled App Connection Method: ${method}`); } diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 1a3afe355..77a9afc8e 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -22,5 +22,6 @@ export enum AppConnection { LDAP = "ldap", TeamCity = "teamcity", OCI = "oci", - OnePass = "1password" + OnePass = "1password", + Render = "render" } diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 890562b6b..e3fddd612 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -20,6 +20,7 @@ import { TMySqlConnection } from "./mysql-connection"; import { TOCIConnection } from "./oci-connection"; import { TOracleDBConnection } from "./oracledb-connection"; import { TPostgresConnection } from "./postgres-connection"; +import { TRenderConnection } from "./render-connection"; import { TTeamCityConnection } from "./teamcity-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; import { TVercelConnection } from "./vercel-connection"; @@ -45,6 +46,7 @@ export * from "./mysql-connection"; export * from "./oci-connection"; export * from "./oracledb-connection"; export * from "./postgres-connection"; +export * from "./render-connection"; export * from "./teamcity-connection"; export * from "./terraform-cloud-connection"; export * from "./vercel-connection"; @@ -74,7 +76,8 @@ export type TAppConnection = | TLdapConnection | TTeamCityConnection | TOCIConnection - | TOnePassConnection; + | TOnePassConnection + | TRenderConnection; export type TAvailableAppConnection = Pick; @@ -126,4 +129,5 @@ export type TAppConnectionMap = { [AppConnection.TeamCity]: TTeamCityConnection; [AppConnection.OCI]: TOCIConnection; [AppConnection.OnePass]: TOnePassConnection; + [AppConnection.Render]: TRenderConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/render-connection.ts b/frontend/src/hooks/api/appConnections/types/render-connection.ts new file mode 100644 index 000000000..bc59c4c39 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/render-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum RenderConnectionMethod { + ApiKey = "api-key" +} + +export type TRenderConnection = TRootAppConnection & { app: AppConnection.Render } & { + method: RenderConnectionMethod.ApiKey; + credentials: { + apiKey: 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 052ff18a7..e3cdc6737 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -29,6 +29,7 @@ import { MySqlConnectionForm } from "./MySqlConnectionForm"; import { OCIConnectionForm } from "./OCIConnectionForm"; import { OracleDBConnectionForm } from "./OracleDBConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; +import { RenderConnectionForm } from "./RenderConnectionForm"; import { TeamCityConnectionForm } from "./TeamCityConnectionForm"; import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; import { VercelConnectionForm } from "./VercelConnectionForm"; @@ -119,6 +120,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.OnePass: return ; + case AppConnection.Render: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -203,6 +206,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.OnePass: return ; + case AppConnection.Render: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RenderConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RenderConnectionForm.tsx new file mode 100644 index 000000000..adf401afe --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RenderConnectionForm.tsx @@ -0,0 +1,132 @@ +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 { RenderConnectionMethod, TRenderConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TRenderConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Render) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(RenderConnectionMethod.ApiKey), + credentials: z.object({ + apiKey: z.string().trim().min(1, "API Key required") + }) + }) +]); + +type FormData = z.infer; + +export const RenderConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Render, + method: RenderConnectionMethod.ApiKey + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +};