diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 2d0de59a2..1799555fb 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2355,6 +2355,9 @@ export const AppConnections = { sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates (true/false). Set to false only in test environments with self-signed certificates.", sslCertificate: "The SSL certificate (PEM format) to use for secure connection." + }, + LARAVEL_FORGE: { + apiToken: "The API token used to authenticate with Laravel Forge." } } }; @@ -2507,6 +2510,14 @@ export const SecretSyncs = { branch: "The branch to sync preview secrets to.", teamId: "The ID of the Vercel team to sync secrets to." }, + LARAVEL_FORGE: { + orgSlug: "The slug of the Laravel Forge org to sync secrets to.", + orgName: "The name of the Laravel Forge org to sync secrets to.", + serverId: "The ID of the Laravel Forge server to sync secrets to.", + serverName: "The name of the Laravel Forge server to sync secrets to.", + siteId: "The ID of the Laravel Forge site to sync secrets to.", + siteName: "The name of the Laravel Forge site to sync secrets to." + }, WINDMILL: { workspace: "The Windmill workspace to sync secrets to.", path: "The Windmill workspace path 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 e5549f9fe..c799ef0f0 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 @@ -77,6 +77,10 @@ import { HumanitecConnectionListItemSchema, SanitizedHumanitecConnectionSchema } from "@app/services/app-connection/humanitec"; +import { + LaravelForgeConnectionListItemSchema, + SanitizedLaravelForgeConnectionSchema +} from "@app/services/app-connection/laravel-forge"; 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"; @@ -158,7 +162,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedNetlifyConnectionSchema.options, ...SanitizedOktaConnectionSchema.options, ...SanitizedAzureADCSConnectionSchema.options, - ...SanitizedRedisConnectionSchema.options + ...SanitizedRedisConnectionSchema.options, + ...SanitizedLaravelForgeConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -200,7 +205,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ NetlifyConnectionListItemSchema, OktaConnectionListItemSchema, AzureADCSConnectionListItemSchema, - RedisConnectionListItemSchema + RedisConnectionListItemSchema, + LaravelForgeConnectionListItemSchema ]); 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 11d9ce5e6..2e3da4420 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -24,6 +24,7 @@ import { registerGitLabConnectionRouter } from "./gitlab-connection-router"; import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router"; import { registerHerokuConnectionRouter } from "./heroku-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; +import { registerLaravelForgeConnectionRouter } from "./laravel-forge-connection-router"; import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; @@ -71,6 +72,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.LaravelForge, + server, + sanitizedResponseSchema: SanitizedLaravelForgeConnectionSchema, + createSchema: CreateLaravelForgeConnectionSchema, + updateSchema: UpdateLaravelForgeConnectionSchema + }); + server.route({ + method: "GET", + url: `/:connectionId/organizations`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const organizations = await server.services.appConnection.laravelForge.listOrganizations( + connectionId, + req.permission + ); + + return organizations; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/servers`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + organizationSlug: z.string() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { organizationSlug } = req.query; + const servers = await server.services.appConnection.laravelForge.listServers( + connectionId, + req.permission, + organizationSlug + ); + + return servers; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/sites`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + organizationSlug: z.string(), + serverId: z.string() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { organizationSlug, serverId } = req.query; + const sites = await server.services.appConnection.laravelForge.listSites( + connectionId, + req.permission, + organizationSlug, + serverId + ); + + 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 fed56277e..e778dbd7c 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 { registerLaravelForgeSyncRouter } from "./laravel-forge-sync-router"; import { registerNetlifySyncRouter } from "./netlify-sync-router"; import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; @@ -63,5 +64,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.LaravelForge, + server, + responseSchema: LaravelForgeSyncSchema, + createSchema: CreateLaravelForgeSyncSchema, + updateSchema: UpdateLaravelForgeSyncSchema + }); 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 71e8b2cca..1bfa32eeb 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 { LaravelForgeSyncListItemSchema, LaravelForgeSyncSchema } from "@app/services/secret-sync/laravel-forge"; 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"; @@ -84,7 +85,8 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ ChecklySyncSchema, DigitalOceanAppPlatformSyncSchema, NetlifySyncSchema, - BitbucketSyncSchema + BitbucketSyncSchema, + LaravelForgeSyncSchema ]); const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ @@ -117,7 +119,8 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ ChecklySyncListItemSchema, SupabaseSyncListItemSchema, NetlifySyncListItemSchema, - BitbucketSyncListItemSchema + BitbucketSyncListItemSchema, + LaravelForgeSyncListItemSchema ]); export const registerSecretSyncRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 996cd872a..54b70c7d3 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -37,7 +37,8 @@ export enum AppConnection { DigitalOcean = "digital-ocean", Netlify = "netlify", Okta = "okta", - Redis = "redis" + Redis = "redis", + LaravelForge = "laravel-forge" } 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 73abef78d..464f718ce 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -103,6 +103,11 @@ import { HumanitecConnectionMethod, validateHumanitecConnectionCredentials } from "./humanitec"; +import { + getLaravelForgeConnectionListItem, + LaravelForgeConnectionMethod, + validateLaravelForgeConnectionCredentials +} from "./laravel-forge"; import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; @@ -187,6 +192,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getOnePassConnectionListItem(), getHerokuConnectionListItem(), getRenderConnectionListItem(), + getLaravelForgeConnectionListItem(), getFlyioConnectionListItem(), getGitLabConnectionListItem(), getCloudflareConnectionListItem(), @@ -316,6 +322,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.LaravelForge]: validateLaravelForgeConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator, @@ -368,6 +375,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: + case LaravelForgeConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -463,7 +471,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, [AppConnection.Netlify]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported, - [AppConnection.Redis]: platformManagedCredentialsNotSupported + [AppConnection.Redis]: platformManagedCredentialsNotSupported, + [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index e3235d2f7..c01d9d1b4 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -28,6 +28,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.OnePass]: "1Password", [AppConnection.Heroku]: "Heroku", [AppConnection.Render]: "Render", + [AppConnection.LaravelForge]: "Laravel Forge", [AppConnection.Flyio]: "Fly.io", [AppConnection.GitLab]: "GitLab", [AppConnection.Cloudflare]: "Cloudflare", @@ -70,6 +71,7 @@ export const APP_CONNECTION_PLAN_MAP: Record { + return { + name: "Laravel Forge" as const, + app: AppConnection.LaravelForge as const, + methods: Object.values(LaravelForgeConnectionMethod) as [LaravelForgeConnectionMethod.ApiToken] + }; +}; + +export const validateLaravelForgeConnectionCredentials = async (config: TLaravelForgeConnectionConfig) => { + const { credentials: inputCredentials } = config; + + try { + // Using the /api/me endpoint to validate the API token + await request.get(`${IntegrationUrls.LARAVELFORGE_API_URL}/api/me`, { + headers: { + Authorization: `Bearer ${inputCredentials.apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + }); + } catch (error) { + 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; +}; + +type TLaravelForgeApiResponse = { + data: T[]; + links?: { + next?: string; + }; + meta?: { + next_cursor?: string; + prev_cursor?: string | null; + }; +}; + +const fetchAllPages = async ( + apiToken: string, + url: string, + params?: Record +): Promise => { + const allItems: T[] = []; + let nextUrl: string | null = url; + const queryParams = params || {}; + + while (nextUrl) { + try { + const response: { data: TLaravelForgeApiResponse } = await request.get>(nextUrl, { + params: queryParams, + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + }); + + if (!response?.data?.data) { + throw new InternalServerError({ + message: `Failed to fetch data from ${url}: Response was empty or malformed` + }); + } + + allItems.push(...response.data.data); + + if (response.data.links?.next) { + nextUrl = response.data.links.next; + } else { + nextUrl = null; + } + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch data from ${url}: ${error.message || "Unknown error"}` + }); + } + throw error; + } + } + + return allItems; +}; + +export const listLaravelForgeOrganizations = async ( + appConnection: TLaravelForgeConnection +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawOrganizations = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs` + ); + + return rawOrganizations.map((org: TRawLaravelForgeOrganization) => ({ + id: org.id, + name: org.attributes.name, + slug: org.attributes.slug + })); +}; + +export const listLaravelForgeServers = async ( + appConnection: TLaravelForgeConnection, + organizationSlug: string +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawServers = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${organizationSlug}/servers` + ); + + return rawServers.map((server: TRawLaravelForgeServer) => ({ + id: server.id, + name: server.attributes.name + })); +}; + +export const listLaravelForgeSites = async ( + appConnection: TLaravelForgeConnection, + organizationSlug: string, + serverId: string +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawSites = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${organizationSlug}/servers/${serverId}/sites` + ); + + return rawSites.map((site: TRawLaravelForgeSite) => ({ + id: site.id, + name: site.attributes.name + })); +}; diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts new file mode 100644 index 000000000..1647b38a1 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts @@ -0,0 +1,58 @@ +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 { LaravelForgeConnectionMethod } from "./laravel-forge-connection-enums"; + +export const LaravelForgeConnectionApiTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API token required").describe(AppConnections.CREDENTIALS.LARAVEL_FORGE.apiToken) +}); + +const BaseLaravelForgeConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.LaravelForge) }); + +export const LaravelForgeConnectionSchema = BaseLaravelForgeConnectionSchema.extend({ + method: z.literal(LaravelForgeConnectionMethod.ApiToken), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema +}); + +export const SanitizedLaravelForgeConnectionSchema = z.discriminatedUnion("method", [ + BaseLaravelForgeConnectionSchema.extend({ + method: z.literal(LaravelForgeConnectionMethod.ApiToken), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateLaravelForgeConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(LaravelForgeConnectionMethod.ApiToken) + .describe(AppConnections.CREATE(AppConnection.LaravelForge).method), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.LaravelForge).credentials + ) + }) +]); + +export const CreateLaravelForgeConnectionSchema = ValidateLaravelForgeConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.LaravelForge) +); + +export const UpdateLaravelForgeConnectionSchema = z + .object({ + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.LaravelForge).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.LaravelForge)); + +export const LaravelForgeConnectionListItemSchema = z.object({ + name: z.literal("Laravel Forge"), + app: z.literal(AppConnection.LaravelForge), + methods: z.nativeEnum(LaravelForgeConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts new file mode 100644 index 000000000..fc3c2bf80 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts @@ -0,0 +1,74 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + listLaravelForgeOrganizations, + listLaravelForgeServers, + listLaravelForgeSites +} from "./laravel-forge-connection-fns"; +import { + TLaravelForgeConnection, + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite +} from "./laravel-forge-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const laravelForgeConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listOrganizations = async ( + connectionId: string, + actor: OrgServiceActor + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const organizations = await listLaravelForgeOrganizations(appConnection); + return organizations; + } catch (error) { + logger.error(error, "Failed to list organizations for Laravel Forge connection"); + return []; + } + }; + + const listServers = async ( + connectionId: string, + actor: OrgServiceActor, + organizationSlug: string + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const servers = await listLaravelForgeServers(appConnection, organizationSlug); + return servers; + } catch (error) { + logger.error(error, "Failed to list servers for Laravel Forge connection"); + return []; + } + }; + + const listSites = async ( + connectionId: string, + actor: OrgServiceActor, + organizationSlug: string, + serverId: string + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const sites = await listLaravelForgeSites(appConnection, organizationSlug, serverId); + return sites; + } catch (error) { + logger.error(error, "Failed to list sites for Laravel Forge connection"); + return []; + } + }; + + return { + listOrganizations, + listServers, + listSites + }; +}; diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts new file mode 100644 index 000000000..ad134ef3d --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts @@ -0,0 +1,63 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateLaravelForgeConnectionSchema, + LaravelForgeConnectionSchema, + ValidateLaravelForgeConnectionCredentialsSchema +} from "./laravel-forge-connection-schemas"; + +export type TLaravelForgeConnection = z.infer; + +export type TLaravelForgeConnectionInput = z.infer & { + app: AppConnection.LaravelForge; +}; + +export type TValidateLaravelForgeConnectionCredentialsSchema = typeof ValidateLaravelForgeConnectionCredentialsSchema; + +export type TLaravelForgeConnectionConfig = DiscriminativePick< + TLaravelForgeConnectionInput, + "method" | "app" | "credentials" +> & { + orgSlug: string; +}; + +export type TLaravelForgeOrganization = { + id: string; + name: string; + slug: string; +}; + +export type TLaravelForgeServer = { + id: string; + name: string; +}; + +export type TLaravelForgeSite = { + id: string; + name: string; +}; + +export type TRawLaravelForgeOrganization = { + id: string; + attributes: { + name: string; + slug: string; + }; +}; + +export type TRawLaravelForgeServer = { + id: string; + attributes: { + name: string; + }; +}; + +export type TRawLaravelForgeSite = { + id: string; + attributes: { + name: string; + }; +}; diff --git a/backend/src/services/secret-sync/laravel-forge/index.ts b/backend/src/services/secret-sync/laravel-forge/index.ts new file mode 100644 index 000000000..f38e2a06b --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/index.ts @@ -0,0 +1,4 @@ +export * from "./laravel-forge-sync-constants"; +export * from "./laravel-forge-sync-fns"; +export * from "./laravel-forge-sync-schemas"; +export * from "./laravel-forge-sync-types"; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts new file mode 100644 index 000000000..7bde155ec --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts @@ -0,0 +1,10 @@ +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 LARAVEL_FORGE_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Laravel Forge", + destination: SecretSync.LaravelForge, + connection: AppConnection.LaravelForge, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts new file mode 100644 index 000000000..bbb0f354e --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts @@ -0,0 +1,207 @@ +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { + LaravelForgeSecret, + TGetLaravelForgeSecrets, + TLaravelForgeSecrets, + TLaravelForgeSyncWithCredentials +} from "./laravel-forge-sync-types"; + +const getLaravelForgeSecretsRaw = async ({ apiToken, orgSlug, serverId, siteId }: TGetLaravelForgeSecrets) => { + const { data } = await request.get( + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${orgSlug}/servers/${serverId}/sites/${siteId}/environment`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + } + ); + + return data.data.attributes.content; +}; + +const parseEnv = (str: string) => { + const lines = str.split("\n"); + const parsed: { key: string; value: string }[] = []; + + let i = 0; + while (i < lines.length) { + const trimmed = lines[i].trim(); + + // Skip empty lines and comments + if (trimmed === "" || trimmed.startsWith("#")) { + i += 1; + // eslint-disable-next-line no-continue + continue; + } + + if (trimmed.includes("=")) { + const equalIndex = trimmed.indexOf("="); + const key = trimmed.substring(0, equalIndex).trim(); + const valueRaw = trimmed.substring(equalIndex + 1).trim(); + + // Check if value starts with a quote + const startsWithDoubleQuote = valueRaw.startsWith('"'); + const startsWithSingleQuote = valueRaw.startsWith("'"); + + if (startsWithDoubleQuote || startsWithSingleQuote) { + const quoteChar = startsWithDoubleQuote ? '"' : "'"; + + const closingQuoteIndex = valueRaw.indexOf(quoteChar, 1); + + if (closingQuoteIndex !== -1) { + // Single-line quoted value + const value = valueRaw.slice(1, closingQuoteIndex); + parsed.push({ key, value }); + i += 1; + } else { + // Multiline quoted value - collect lines until closing quote + let value = valueRaw.slice(1); + i += 1; + + while (i < lines.length) { + const nextLine = lines[i]; + const closingIndex = nextLine.indexOf(quoteChar); + + if (closingIndex !== -1) { + value += `\n${nextLine.substring(0, closingIndex)}`; + parsed.push({ key, value }); + i += 1; + break; + } else { + value += `\n${nextLine}`; + i += 1; + } + } + } + } else { + // Unquoted value + parsed.push({ key, value: valueRaw }); + i += 1; + } + } else { + i += 1; + } + } + + return parsed; +}; + +const getLaravelForgeSecrets = async (secretSync: TLaravelForgeSyncWithCredentials): Promise => { + const { + connection, + destinationConfig: { orgSlug, serverId, siteId } + } = secretSync; + + const { apiToken } = connection.credentials; + + const secrets = await getLaravelForgeSecretsRaw({ apiToken, orgSlug, serverId, siteId }); + + const parsedSecrets = parseEnv(secrets); + + return parsedSecrets; +}; + +const buildEnvString = (secrets: LaravelForgeSecret[]) => { + if (secrets.length === 0) { + return "# .env"; + } + + return secrets + .map((secret) => { + const { value } = secret; + + if (value.includes(`"`)) { + return `${secret.key}='${value}'`; + } + + if (value.includes(" ") || value.includes("\n") || value.includes(`'`)) { + return `${secret.key}="${value}"`; + } + return `${secret.key}=${value}`; + }) + .join("\n"); +}; + +const updateLaravelForgeSecrets = async (secretSync: TLaravelForgeSyncWithCredentials, envString: string) => { + const { + connection, + destinationConfig: { orgSlug, serverId, siteId } + } = secretSync; + + const { apiToken } = connection.credentials; + + await request.put( + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${orgSlug}/servers/${serverId}/sites/${siteId}/environment`, + { + environment: envString + }, + + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + } + ); +}; + +export const LaravelForgeSyncFns = { + async syncSecrets(secretSync: TLaravelForgeSyncWithCredentials, secretMap: TSecretMap) { + const { + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + + const secrets = await getLaravelForgeSecrets(secretSync); + + // Create a map of the existing secrets + const updatedSecretsMap = new Map(secrets.map((secret) => [secret.key, secret.value])); + + for (const [key, { value }] of Object.entries(secretMap)) { + // Add the new secrets to the map + updatedSecretsMap.set(key, value); + } + + if (!disableSecretDeletion) { + secrets.forEach((secret) => { + if (!matchesSchema(secret.key, environment?.slug || "", keySchema)) return; + + if (!secretMap[secret.key]) { + updatedSecretsMap.delete(secret.key); + } + }); + } + + const updatedSecrets = Array.from(updatedSecretsMap.entries()).map(([key, value]) => ({ key, value })); + + const envString = buildEnvString(updatedSecrets); + + await updateLaravelForgeSecrets(secretSync, envString); + }, + + async getSecrets(secretSync: TLaravelForgeSyncWithCredentials): Promise { + const secrets = await getLaravelForgeSecrets(secretSync); + return Object.fromEntries(secrets.map((secret) => [secret.key, { value: secret.value }])); + }, + + async removeSecrets(secretSync: TLaravelForgeSyncWithCredentials, secretMap: TSecretMap) { + const existingSecrets = await getLaravelForgeSecrets(secretSync); + + const newSecrets = existingSecrets.filter((secret) => !Object.hasOwn(secretMap, secret.key)); + + if (newSecrets.length === existingSecrets.length) { + return; + } + + const envString = buildEnvString(newSecrets); + + await updateLaravelForgeSecrets(secretSync, envString); + } +}; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts new file mode 100644 index 000000000..168ebde3b --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts @@ -0,0 +1,68 @@ +import RE2 from "re2"; +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"; + +const slugValidator = (val: string) => { + return new RE2("^[a-z0-9.-]+$").test(val) && !new RE2(".[-]$").test(val); +}; + +const LaravelForgeSyncDestinationConfigSchema = z.object({ + orgSlug: z + .string() + .min(1, "Org Slug is required") + .max(512, "Org Slug cannot exceed 512 characters") + .refine( + (val) => slugValidator(val), + "Org Slug can only contain lowercase letters, numbers, dots, and dashes, and cannot end with a dot or dash." + ) + .describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.orgSlug), + orgName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.orgName), + serverId: z + .string() + .min(1, "Server ID is required") + .refine((val) => !Number.isNaN(Number(val)), "Server ID must be a valid integer") + .describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.serverId), + serverName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.serverName), + siteId: z.string().min(1, "Site ID is required").describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.siteId), + siteName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.siteName) +}); + +const LaravelForgeSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const LaravelForgeSyncSchema = BaseSecretSyncSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destination: z.literal(SecretSync.LaravelForge), + destinationConfig: LaravelForgeSyncDestinationConfigSchema +}); + +export const CreateLaravelForgeSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destinationConfig: LaravelForgeSyncDestinationConfigSchema +}); + +export const UpdateLaravelForgeSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destinationConfig: LaravelForgeSyncDestinationConfigSchema.optional() +}); + +export const LaravelForgeSyncListItemSchema = z.object({ + name: z.literal("Laravel Forge"), + connection: z.literal(AppConnection.LaravelForge), + destination: z.literal(SecretSync.LaravelForge), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts new file mode 100644 index 000000000..faa5dffa4 --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts @@ -0,0 +1,41 @@ +import z from "zod"; + +import { TLaravelForgeConnection } from "@app/services/app-connection/laravel-forge"; + +import { + CreateLaravelForgeSyncSchema, + LaravelForgeSyncListItemSchema, + LaravelForgeSyncSchema +} from "./laravel-forge-sync-schemas"; + +export type TLaravelForgeSyncListItem = z.infer; + +export type TLaravelForgeSync = z.infer; + +export type TLaravelForgeSyncInput = z.infer; + +export type TLaravelForgeSyncWithCredentials = TLaravelForgeSync & { + connection: TLaravelForgeConnection; +}; + +export type TGetLaravelForgeSecrets = { + apiToken: string; + orgSlug: string; + serverId: string; + siteId: string; +}; + +export type TLaravelForgeSecrets = { + data: { + id: string; + type: string; + attributes: { + content: string; + }; + }; +}; + +export type LaravelForgeSecret = { + key: string; + value: string; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index fe0dc9f56..235b3db3a 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -28,7 +28,8 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + LaravelForge = "laravel-forge" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index a6faebd1e..85fc27250 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -49,6 +49,8 @@ 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 { LARAVEL_FORGE_SYNC_LIST_OPTION } from "./laravel-forge"; +import { LaravelForgeSyncFns } from "./laravel-forge/laravel-forge-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"; @@ -91,7 +93,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [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 + [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION, + [SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -277,6 +280,8 @@ export const SecretSyncFns = { return NetlifySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.LaravelForge: + return LaravelForgeSyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -393,6 +398,9 @@ export const SecretSyncFns = { case SecretSync.Bitbucket: secretMap = await BitbucketSyncFns.getSecrets(secretSync); break; + case SecretSync.LaravelForge: + secretMap = await LaravelForgeSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -486,6 +494,8 @@ export const SecretSyncFns = { return NetlifySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.LaravelForge: + return LaravelForgeSyncFns.removeSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 1fbc66cca..0ec8aede0 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -32,7 +32,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Checkly]: "Checkly", [SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform", [SecretSync.Netlify]: "Netlify", - [SecretSync.Bitbucket]: "Bitbucket" + [SecretSync.Bitbucket]: "Bitbucket", + [SecretSync.LaravelForge]: "Laravel Forge" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -65,7 +66,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, - [SecretSync.Bitbucket]: AppConnection.Bitbucket + [SecretSync.Bitbucket]: AppConnection.Bitbucket, + [SecretSync.LaravelForge]: AppConnection.LaravelForge }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -98,7 +100,8 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Checkly]: SecretSyncPlanType.Regular, [SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular, [SecretSync.Netlify]: SecretSyncPlanType.Regular, - [SecretSync.Bitbucket]: SecretSyncPlanType.Regular + [SecretSync.Bitbucket]: SecretSyncPlanType.Regular, + [SecretSync.LaravelForge]: SecretSyncPlanType.Regular }; export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { @@ -140,7 +143,8 @@ export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { [SecretSync.Checkly]: ["groupName", "accountName"], [SecretSync.DigitalOceanAppPlatform]: ["appName"], [SecretSync.Netlify]: ["accountName", "siteName"], - [SecretSync.Bitbucket]: [] + [SecretSync.Bitbucket]: [], + [SecretSync.LaravelForge]: [] }; const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true; @@ -199,5 +203,6 @@ export const DESTINATION_DUPLICATE_CHECK_MAP: Record + Check out the configuration docs for [Laravel Forge + Connections](/integrations/app-connections/laravel-forge) to learn how to + obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx new file mode 100644 index 000000000..e2d8a0f02 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/laravel-forge/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx new file mode 100644 index 000000000..675551508 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/laravel-forge/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx new file mode 100644 index 000000000..541a393f9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/laravel-forge/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx new file mode 100644 index 000000000..209eb6514 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx new file mode 100644 index 000000000..b00d06818 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/laravel-forge/{connectionId}" +--- + + + Check out the configuration docs for [Laravel Forge + Connections](/integrations/app-connections/laravel-forge) to learn how to + obtain the required credentials. + diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx new file mode 100644 index 000000000..076571648 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx new file mode 100644 index 000000000..303c82079 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx new file mode 100644 index 000000000..12602b1db --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx new file mode 100644 index 000000000..f5082df25 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/laravel-forge/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx new file mode 100644 index 000000000..c86085e95 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx new file mode 100644 index 000000000..fa64bf933 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx new file mode 100644 index 000000000..7b4c3a752 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx new file mode 100644 index 000000000..ce638b4cb --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx new file mode 100644 index 000000000..9f9f4a28f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index ee817ab9f..1129aa923 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -125,6 +125,7 @@ "integrations/app-connections/hashicorp-vault", "integrations/app-connections/heroku", "integrations/app-connections/humanitec", + "integrations/app-connections/laravel-forge", "integrations/app-connections/ldap", "integrations/app-connections/mssql", "integrations/app-connections/mysql", @@ -550,6 +551,7 @@ "integrations/secret-syncs/hashicorp-vault", "integrations/secret-syncs/heroku", "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/laravel-forge", "integrations/secret-syncs/netlify", "integrations/secret-syncs/oci-vault", "integrations/secret-syncs/railway", @@ -1778,6 +1780,18 @@ "api-reference/endpoints/app-connections/humanitec/delete" ] }, + { + "group": "Laravel Forge", + "pages": [ + "api-reference/endpoints/app-connections/laravel-forge/list", + "api-reference/endpoints/app-connections/laravel-forge/available", + "api-reference/endpoints/app-connections/laravel-forge/get-by-id", + "api-reference/endpoints/app-connections/laravel-forge/get-by-name", + "api-reference/endpoints/app-connections/laravel-forge/create", + "api-reference/endpoints/app-connections/laravel-forge/update", + "api-reference/endpoints/app-connections/laravel-forge/delete" + ] + }, { "group": "LDAP", "pages": [ @@ -2258,6 +2272,19 @@ "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] }, + { + "group": "Laravel Forge", + "pages": [ + "api-reference/endpoints/secret-syncs/laravel-forge/list", + "api-reference/endpoints/secret-syncs/laravel-forge/get-by-id", + "api-reference/endpoints/secret-syncs/laravel-forge/get-by-name", + "api-reference/endpoints/secret-syncs/laravel-forge/create", + "api-reference/endpoints/secret-syncs/laravel-forge/update", + "api-reference/endpoints/secret-syncs/laravel-forge/delete", + "api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets", + "api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets" + ] + }, { "group": "Netlify", "pages": [ diff --git a/docs/images/app-connections/laravel-forge/api-token-create-form.png b/docs/images/app-connections/laravel-forge/api-token-create-form.png new file mode 100644 index 000000000..895da4d77 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/api-token-create-form.png differ diff --git a/docs/images/app-connections/laravel-forge/api-token-generated.png b/docs/images/app-connections/laravel-forge/api-token-generated.png new file mode 100644 index 000000000..8edc62261 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/api-token-generated.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png b/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png new file mode 100644 index 000000000..69fe3b2b3 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-form.png b/docs/images/app-connections/laravel-forge/app-connection-form.png new file mode 100644 index 000000000..e03289a65 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-form.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-generated.png b/docs/images/app-connections/laravel-forge/app-connection-generated.png new file mode 100644 index 000000000..badc5d8c5 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-generated.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-option.png b/docs/images/app-connections/laravel-forge/app-connection-option.png new file mode 100644 index 000000000..50ac89c27 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-option.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-profile.png b/docs/images/app-connections/laravel-forge/app-connection-profile.png new file mode 100644 index 000000000..18e66a5a5 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-profile.png differ diff --git a/docs/images/secret-syncs/laravel-forge/select-option.png b/docs/images/secret-syncs/laravel-forge/select-option.png new file mode 100644 index 000000000..a10f3a437 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/select-option.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-created.png b/docs/images/secret-syncs/laravel-forge/sync-created.png new file mode 100644 index 000000000..3e85f0879 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-created.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-destination.png b/docs/images/secret-syncs/laravel-forge/sync-destination.png new file mode 100644 index 000000000..735471a7a Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-destination.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-details.png b/docs/images/secret-syncs/laravel-forge/sync-details.png new file mode 100644 index 000000000..3588f88db Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-details.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-options.png b/docs/images/secret-syncs/laravel-forge/sync-options.png new file mode 100644 index 000000000..0c83e0a19 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-options.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-review.png b/docs/images/secret-syncs/laravel-forge/sync-review.png new file mode 100644 index 000000000..2d617a8b0 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-review.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-source.png b/docs/images/secret-syncs/laravel-forge/sync-source.png new file mode 100644 index 000000000..5bffb0ba9 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-source.png differ diff --git a/docs/integrations/app-connections/laravel-forge.mdx b/docs/integrations/app-connections/laravel-forge.mdx new file mode 100644 index 000000000..67ce55270 --- /dev/null +++ b/docs/integrations/app-connections/laravel-forge.mdx @@ -0,0 +1,107 @@ +--- +title: "Laravel Forge Connection" +description: "Learn how to configure a Laravel Forge Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://forge.laravel.com/docs/api#create-a-new-api-token) to connect with Laravel Forge. + +## Create Laravel Forge API Token + + + + ![Laravel Forge User Settings](/images/app-connections/laravel-forge/app-connection-profile.png) + + + ![Applications Tab](/images/app-connections/laravel-forge/app-connection-create-api-token.png) + + + Provide a name for your token and select the following permissions: + - `user:view` + - `organization:view` + - `server:view` + - `site:manage-environment` + + Then click 'Add token'. + + ![Token Form](/images/app-connections/laravel-forge/api-token-create-form.png) + + + + Make sure to copy the token now—you won’t be able to access it again. + + ![Token Generated](/images/app-connections/laravel-forge/api-token-generated.png) + + + + +## Create a Laravel Forge Connection in Infisical + + + + + + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Laravel Forge** Connection from the list of integrations. + ![Select Laravel Forge Connection](/images/app-connections/laravel-forge/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 + ![Laravel Forge Connection Modal](/images/app-connections/laravel-forge/app-connection-form.png) + + + After submitting the form, your **Laravel Forge Connection** will be successfully created and ready to use with your Infisical project. + ![Laravel Forge Connection Created](/images/app-connections/laravel-forge/app-connection-generated.png) + + + + + + + To create a Laravel Forge Connection via API, send a request to the [Create Laravel Forge Connection](/api-reference/endpoints/app-connections/laravel-forge/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/laravel-forge \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-laravel-forge-connection", + "method": "api-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "credentials": { + "apiToken": "[API TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "name": "my-laravel-forge-connection", + "description": null, + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "version": 1, + "orgId": "abcdef12-3456-7890-abcd-ef1234567890", + "createdAt": "2025-10-13T10:15:00.000Z", + "updatedAt": "2025-10-13T10:15:00.000Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "d41d8cd98f00b204e9800998ecf8427e", + "app": "laravel-forge", + "method": "api-token", + "credentials": {} + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/laravel-forge.mdx b/docs/integrations/secret-syncs/laravel-forge.mdx new file mode 100644 index 000000000..c93abb94a --- /dev/null +++ b/docs/integrations/secret-syncs/laravel-forge.mdx @@ -0,0 +1,157 @@ +--- +title: "Laravel Forge Sync" +description: "Learn how to configure a Laravel Forge Sync for Infisical." +--- + +**Prerequisites:** + +- Create a [Laravel Forge Connection](/integrations/app-connections/laravel-forge) + + + + + + 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 Laravel Forge](/images/secret-syncs/laravel-forge/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/laravel-forge/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/laravel-forge/sync-destination.png) + + - **Laravel Forge Connection**: The Laravel Forge Connection to authenticate with. + - **Organization**: The Organization in which the server and site reside. + - **Server**: The Server on which the site resides. + - **Site**: The Site for which secrets should be synced. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/laravel-forge/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 Laravel Forge when keys conflict. + - **Import Secrets (Prioritize Laravel Forge)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Laravel Forge over Infisical when keys conflict. + - **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 Laravel Forge Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/laravel-forge/sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Laravel Forge Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/laravel-forge/sync-review.png) + + + If enabled, your Laravel Forge Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/laravel-forge/sync-created.png) + + + + + + + To create a **Laravel Forge Sync**, make an API request to the [Create Laravel Forge Sync](/api-reference/endpoints/secret-syncs/laravel-forge/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/laravel-forge \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-laravel-forge-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "sync to laravel forge site", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isEnabled": true, + "isAutoSyncEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": false + }, + "destinationConfig": { + "orgSlug": "org-abc123", + "serverId": "123", + "siteId": "site-abc123" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-laravel-forge-sync", + "description": "sync to laravel forge site", + "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", + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "laravel-forge", + "name": "my-laravel-forge-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "destination": "laravel-forge", + "destinationConfig": { + "orgSlug": "org-abc123", + "serverId": "123", + "siteId": "site-abc123" + } + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/netlify.mdx b/docs/integrations/secret-syncs/netlify.mdx index fd98fac41..c1c51d452 100644 --- a/docs/integrations/secret-syncs/netlify.mdx +++ b/docs/integrations/secret-syncs/netlify.mdx @@ -78,6 +78,7 @@ description: "Learn how to configure a Netlify Sync for Infisical." ![Sync Created](/images/secret-syncs/netlify/sync-created.png) + @@ -157,5 +158,6 @@ description: "Learn how to configure a Netlify Sync for Infisical." } } ``` + diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index 951a643dd..cfc65d1fd 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -45,7 +45,8 @@ export const AppConnectionsBrowser = () => { {"name": "Redis", "slug": "redis", "path": "/integrations/app-connections/redis", "description": "Learn how to connect Redis to pull secrets from Infisical.", "category": "Databases"}, {"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"}, {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, - {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"} + {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"}, + {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/app-connections/laravel-forge", "description": "Learn how to connect your Laravel Forge to pull secrets from Infisical.", "category": "Hosting"}, ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/docs/snippets/SecretSyncsBrowser.jsx b/docs/snippets/SecretSyncsBrowser.jsx index d32f5ac04..3598bf68e 100644 --- a/docs/snippets/SecretSyncsBrowser.jsx +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -36,7 +36,8 @@ export const SecretSyncsBrowser = () => { {"name": "Camunda", "slug": "camunda", "path": "/integrations/secret-syncs/camunda", "description": "Learn how to sync secrets from Infisical to Camunda.", "category": "DevOps Tools"}, {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "category": "DevOps Tools"}, {"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"}, - {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"} + {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"}, + {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/secret-syncs/laravel-forge", "description": "Learn how to sync secrets from Infisical to Laravel Forge.", "category": "Hosting"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx b/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx index 7257b3d04..ebbe34d75 100644 --- a/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx +++ b/frontend/src/components/secret-syncs/SecretSyncModalHeader.tsx @@ -17,7 +17,7 @@ export const SecretSyncModalHeader = ({ destination, isConfigured }: Props) => { {`${destinationDetails.name}
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/LaravelForgeSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/LaravelForgeSyncFields.tsx new file mode 100644 index 000000000..90409da4f --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/LaravelForgeSyncFields.tsx @@ -0,0 +1,138 @@ +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 { + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite, + useLaravelForgeConnectionListOrganizations, + useLaravelForgeConnectionListServers, + useLaravelForgeConnectionListSites +} from "@app/hooks/api/appConnections/laravel-forge"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const LaravelForgeSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.LaravelForge } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const orgSlug = useWatch({ name: "destinationConfig.orgSlug", control }); + const serverId = useWatch({ name: "destinationConfig.serverId", control }); + + const { data: organizations, isLoading: isOrganizationsLoading } = + useLaravelForgeConnectionListOrganizations(connectionId, { + enabled: Boolean(connectionId) + }); + + const { data: servers, isLoading: isServersLoading } = useLaravelForgeConnectionListServers( + connectionId, + orgSlug, + { + enabled: Boolean(connectionId && orgSlug) + } + ); + + const { data: sites, isLoading: isSitesLoading } = useLaravelForgeConnectionListSites( + connectionId, + orgSlug, + serverId, + { + enabled: Boolean(connectionId && orgSlug && serverId) + } + ); + + const handleChangeConnection = () => { + setValue("destinationConfig.orgSlug", ""); + setValue("destinationConfig.serverId", ""); + setValue("destinationConfig.siteId", ""); + setValue("destinationConfig.orgName", ""); + setValue("destinationConfig.serverName", ""); + setValue("destinationConfig.siteName", ""); + }; + + return ( + <> + + + ( + + org.slug === value) ?? null} + onChange={(option) => { + const selectedOrg = option as SingleValue; + onChange(selectedOrg?.slug ?? ""); + setValue("destinationConfig.orgName", selectedOrg?.name ?? ""); + setValue("destinationConfig.serverId", ""); + setValue("destinationConfig.siteId", ""); + }} + options={organizations} + placeholder="Select an organization..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ( + + server.id === value) ?? null} + onChange={(option) => { + const selectedServer = option as SingleValue; + onChange(selectedServer?.id ?? ""); + setValue("destinationConfig.serverName", selectedServer?.name ?? ""); + setValue("destinationConfig.siteId", ""); + }} + options={servers} + placeholder="Select a server..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ( + + site.id === value) ?? null} + onChange={(option) => { + const selectedSite = option as SingleValue; + onChange(selectedSite?.id ?? ""); + setValue("destinationConfig.siteName", selectedSite?.name ?? ""); + }} + options={sites} + placeholder="Select a site..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index b2186fed1..61ba54369 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 { LaravelForgeSyncFields } from "./LaravelForgeSyncFields"; import { NetlifySyncFields } from "./NetlifySyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; import { RailwaySyncFields } from "./RailwaySyncFields"; @@ -100,6 +101,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Bitbucket: return ; + case SecretSync.LaravelForge: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 4fe457179..f66cadaea 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -69,6 +69,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.DigitalOceanAppPlatform: case SecretSync.Netlify: case SecretSync.Bitbucket: + case SecretSync.LaravelForge: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/LaravelForgeSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/LaravelForgeSyncReviewFields.tsx new file mode 100644 index 000000000..a359359f5 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/LaravelForgeSyncReviewFields.tsx @@ -0,0 +1,23 @@ +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 LaravelForgeSyncReviewFields = () => { + const { watch } = useFormContext(); + const orgName = watch("destinationConfig.orgName"); + const orgSlug = watch("destinationConfig.orgSlug"); + const serverName = watch("destinationConfig.serverName"); + const serverId = watch("destinationConfig.serverId"); + const siteName = watch("destinationConfig.siteName"); + const siteId = watch("destinationConfig.siteId"); + + return ( + <> + {orgName || orgSlug} + {serverName || serverId || "None"} + {siteName || siteId || "None"} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index d6c34fb87..44d35cd2c 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -35,6 +35,7 @@ import { GitLabSyncReviewFields } from "./GitLabSyncReviewFields"; import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { LaravelForgeSyncReviewFields } from "./LaravelForgeSyncReviewFields"; import { NetlifySyncReviewFields } from "./NetlifySyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; @@ -168,6 +169,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Bitbucket: DestinationFieldsComponent = ; break; + case SecretSync.LaravelForge: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/laravel-forge-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/laravel-forge-sync-destination-schema.ts new file mode 100644 index 000000000..011f835b7 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/laravel-forge-sync-destination-schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const LaravelForgeSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.LaravelForge), + destinationConfig: z.object({ + orgSlug: z.string().trim().min(1, "Org Slug required"), + orgName: z.string().trim().min(1, "Org Name required"), + serverId: z.string().trim().min(1, "Server ID required"), + serverName: z.string().trim().min(1, "Server Name required"), + siteId: z.string().trim().min(1, "Site ID required"), + siteName: z.string().trim().min(1, "Site Name required") + }) + }) +); 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 f4616c711..5ebc38184 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 { LaravelForgeSyncDestinationSchema } from "./laravel-forge-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"; @@ -61,7 +62,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ ChecklySyncDestinationSchema, DigitalOceanAppPlatformSyncDestinationSchema, NetlifySyncDestinationSchema, - BitbucketSyncDestinationSchema + BitbucketSyncDestinationSchema, + LaravelForgeSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index d0e3dcba1..bdb26c796 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -48,6 +48,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 { LaravelForgeConnectionMethod } from "@app/hooks/api/appConnections/types/laravel-forge-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"; @@ -56,7 +57,13 @@ import { SupabaseConnectionMethod } from "@app/hooks/api/appConnections/types/su export const APP_CONNECTION_MAP: Record< AppConnection, - { name: string; image: string; size?: number; icon?: IconDefinition; enterprise?: boolean } + { + name: string; + image: string; + size?: number; + icon?: IconDefinition; + enterprise?: boolean; + } > = { [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" }, @@ -115,7 +122,12 @@ export const APP_CONNECTION_MAP: Record< image: "Netlify.png" }, [AppConnection.Okta]: { name: "Okta", image: "Okta.png" }, - [AppConnection.Redis]: { name: "Redis", image: "Redis.png" } + [AppConnection.Redis]: { name: "Redis", image: "Redis.png" }, + [AppConnection.LaravelForge]: { + name: "Laravel Forge", + image: "Laravel Forge.png", + size: 65 + } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -151,6 +163,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: + case LaravelForgeConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 6ce82cfb2..12cae6ea6 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -113,6 +113,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, - [SecretSync.Bitbucket]: AppConnection.Bitbucket + [SecretSync.Bitbucket]: AppConnection.Bitbucket, + [SecretSync.LaravelForge]: AppConnection.LaravelForge }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index e897cf0f0..66fed8a5d 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -37,5 +37,6 @@ export enum AppConnection { DigitalOcean = "digital-ocean", Netlify = "netlify", Okta = "okta", - Redis = "redis" + Redis = "redis", + LaravelForge = "laravel-forge" } diff --git a/frontend/src/hooks/api/appConnections/laravel-forge/index.ts b/frontend/src/hooks/api/appConnections/laravel-forge/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/laravel-forge/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/laravel-forge/queries.tsx b/frontend/src/hooks/api/appConnections/laravel-forge/queries.tsx new file mode 100644 index 000000000..6986a94da --- /dev/null +++ b/frontend/src/hooks/api/appConnections/laravel-forge/queries.tsx @@ -0,0 +1,104 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TLaravelForgeOrganization, TLaravelForgeServer, TLaravelForgeSite } from "./types"; + +const laravelForgeConnectionKeys = { + all: [...appConnectionKeys.all, "laravel-forge"] as const, + listOrganizations: (connectionId: string) => + [...laravelForgeConnectionKeys.all, "organizations", connectionId] as const, + listServers: (connectionId: string, organizationSlug: string) => + [...laravelForgeConnectionKeys.all, "servers", connectionId, organizationSlug] as const, + listSites: (connectionId: string, organizationSlug: string, serverId: string) => + [...laravelForgeConnectionKeys.all, "sites", connectionId, organizationSlug, serverId] as const +}; + +export const useLaravelForgeConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TLaravelForgeOrganization[], + unknown, + TLaravelForgeOrganization[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: laravelForgeConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/laravel-forge/${connectionId}/organizations` + ); + + return data; + }, + ...options + }); +}; + +export const useLaravelForgeConnectionListServers = ( + connectionId: string, + organizationSlug: string, + options?: Omit< + UseQueryOptions< + TLaravelForgeServer[], + unknown, + TLaravelForgeServer[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: laravelForgeConnectionKeys.listServers(connectionId, organizationSlug), + queryFn: async () => { + const params = { organizationSlug }; + const { data } = await apiRequest.get( + `/api/v1/app-connections/laravel-forge/${connectionId}/servers`, + { params } + ); + + return data; + }, + enabled: Boolean(connectionId && organizationSlug), + ...options + }); +}; + +export const useLaravelForgeConnectionListSites = ( + connectionId: string, + organizationSlug: string, + serverId: string, + options?: Omit< + UseQueryOptions< + TLaravelForgeSite[], + unknown, + TLaravelForgeSite[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: laravelForgeConnectionKeys.listSites(connectionId, organizationSlug, serverId), + queryFn: async () => { + const params = { + organizationSlug, + serverId + }; + + const { data } = await apiRequest.get( + `/api/v1/app-connections/laravel-forge/${connectionId}/sites`, + { params } + ); + + return data; + }, + enabled: Boolean(connectionId && organizationSlug && serverId), + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/laravel-forge/types.ts b/frontend/src/hooks/api/appConnections/laravel-forge/types.ts new file mode 100644 index 000000000..5a646df3f --- /dev/null +++ b/frontend/src/hooks/api/appConnections/laravel-forge/types.ts @@ -0,0 +1,15 @@ +export type TLaravelForgeOrganization = { + id: string; + name: string; + slug: string; +}; + +export type TLaravelForgeServer = { + id: string; + name: string; +}; + +export type TLaravelForgeSite = { + 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 fdaae2c74..797e7a7c6 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -164,6 +164,10 @@ export type TOktaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Okta; }; +export type TLaravelForgeConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.LaravelForge; +}; + export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & { app: AppConnection.AzureADCS; }; @@ -210,7 +214,8 @@ export type TAppConnectionOption = | TDigitalOceanConnectionOption | TNetlifyConnectionOption | TOktaConnectionOption - | TAzureAdCsConnectionOption; + | TAzureAdCsConnectionOption + | TLaravelForgeConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -252,4 +257,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Okta]: TOktaConnectionOption; [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; [AppConnection.Redis]: TRedisConnectionOption; + [AppConnection.LaravelForge]: TLaravelForgeConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 9f8df7cfa..fd840d5de 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -22,6 +22,7 @@ import { TGitLabConnection } from "./gitlab-connection"; import { THCVaultConnection } from "./hc-vault-connection"; import { THerokuConnection } from "./heroku-connection"; import { THumanitecConnection } from "./humanitec-connection"; +import { TLaravelForgeConnection } from "./laravel-forge-connection"; import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TMySqlConnection } from "./mysql-connection"; @@ -61,6 +62,7 @@ export * from "./gitlab-connection"; export * from "./hc-vault-connection"; export * from "./heroku-connection"; export * from "./humanitec-connection"; +export * from "./laravel-forge-connection"; export * from "./ldap-connection"; export * from "./mssql-connection"; export * from "./mysql-connection"; @@ -105,6 +107,7 @@ export type TAppConnection = | TOCIConnection | TOnePassConnection | THerokuConnection + | TLaravelForgeConnection | TRenderConnection | TFlyioConnection | TGitLabConnection diff --git a/frontend/src/hooks/api/appConnections/types/laravel-forge-connection.ts b/frontend/src/hooks/api/appConnections/types/laravel-forge-connection.ts new file mode 100644 index 000000000..ac48d929d --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/laravel-forge-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 LaravelForgeConnectionMethod { + ApiToken = "api-token" +} + +export type TLaravelForgeConnection = TRootAppConnection & { app: AppConnection.LaravelForge } & { + method: LaravelForgeConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index c230cd9c6..efcc04b6d 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -28,7 +28,8 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + LaravelForge = "laravel-forge" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index f7acda1f4..3cab195bd 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 { TLaravelForgeSync } from "./laravel-forge-sync"; import { TNetlifySync } from "./netlify-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TRailwaySync } from "./railway-sync"; @@ -69,7 +70,8 @@ export type TSecretSync = | TSupabaseSync | TDigitalOceanAppPlatformSync | TNetlifySync - | TBitbucketSync; + | TBitbucketSync + | TLaravelForgeSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/laravel-forge-sync.ts b/frontend/src/hooks/api/secretSyncs/types/laravel-forge-sync.ts new file mode 100644 index 000000000..cce693a48 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/laravel-forge-sync.ts @@ -0,0 +1,20 @@ +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 TLaravelForgeSync = TRootSecretSync & { + destination: SecretSync.LaravelForge; + destinationConfig: { + orgSlug: string; + orgName: string; + serverId: string; + serverName: string; + siteId: string; + siteName: string; + }; + connection: { + app: AppConnection.LaravelForge; + 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 42b00f44a..a44c4746b 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -31,6 +31,7 @@ import { GitLabConnectionForm } from "./GitLabConnectionForm"; import { HCVaultConnectionForm } from "./HCVaultConnectionForm"; import { HerokuConnectionForm } from "./HerokuAppConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; +import { LaravelForgeConnectionForm } from "./LaravelForgeConnectionForm"; import { LdapConnectionForm } from "./LdapConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { MySqlConnectionForm } from "./MySqlConnectionForm"; @@ -146,6 +147,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { return ; case AppConnection.Render: return ; + case AppConnection.LaravelForge: + return ; case AppConnection.Flyio: return ; case AppConnection.GitLab: @@ -297,6 +300,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { ); case AppConnection.Render: return ; + case AppConnection.LaravelForge: + return ; case AppConnection.Flyio: return ; case AppConnection.GitLab: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LaravelForgeConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LaravelForgeConnectionForm.tsx new file mode 100644 index 000000000..f115942eb --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LaravelForgeConnectionForm.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 { + LaravelForgeConnectionMethod, + TLaravelForgeConnection +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TLaravelForgeConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.LaravelForge) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(LaravelForgeConnectionMethod.ApiToken), + credentials: z.object({ + apiToken: z.string().trim().min(1, "API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const LaravelForgeConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.LaravelForge, + method: LaravelForgeConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx index 5774e7707..f77157945 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx @@ -19,7 +19,7 @@ export const AppConnectionHeader = ({ app, isConnected, onBack }: Props) => { {`${appDetails.name} {appDetails.icon && ( { } className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600" > -
+ {image && ( { className="mt-auto" alt={`${name} logo`} /> - {icon && ( + )} + {icon && ( +
- )} -
+
+ )}
{name}
diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/LaravelForgeSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/LaravelForgeSyncDestinationCol.tsx new file mode 100644 index 000000000..0b5dad247 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/LaravelForgeSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TLaravelForgeSync } from "@app/hooks/api/secretSyncs/types/laravel-forge-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TLaravelForgeSync; +}; + +export const LaravelForgeSyncDestinationCol = ({ 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 009af9930..1e4df7351 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 { LaravelForgeSyncDestinationCol } from "./LaravelForgeSyncDestinationCol"; import { NetlifySyncDestinationCol } from "./NetlifySyncDestinationCol"; import { OCIVaultSyncDestinationCol } from "./OCIVaultSyncDestinationCol"; import { RailwaySyncDestinationCol } from "./RailwaySyncDestinationCol"; @@ -97,6 +98,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Bitbucket: return ; + case SecretSync.LaravelForge: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` 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 bb0246236..c4a3f2a37 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 @@ -194,6 +194,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.workspaceSlug; secondaryText = destinationConfig.repositorySlug; break; + case SecretSync.LaravelForge: + primaryText = destinationConfig.siteName || destinationConfig.siteId; + secondaryText = destinationConfig.orgName || destinationConfig.orgSlug; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/LaravelForgeSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/LaravelForgeSyncDestinationSection.tsx new file mode 100644 index 000000000..7d78fd770 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/LaravelForgeSyncDestinationSection.tsx @@ -0,0 +1,24 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TLaravelForgeSync } from "@app/hooks/api/secretSyncs/types/laravel-forge-sync"; + +type Props = { + secretSync: TLaravelForgeSync; +}; + +export const LaravelForgeSyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + return ( + <> + + {destinationConfig.orgName || destinationConfig.orgSlug} + + + {destinationConfig.serverName || destinationConfig.serverId} + + + {destinationConfig.siteName || destinationConfig.siteId} + + + ); +}; 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 7ab07c8d1..78e00d8fa 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 { LaravelForgeSyncDestinationSection } from "./LaravelForgeSyncDestinationSection"; import { NetlifySyncDestinationSection } from "./NetlifySyncDestinationSection"; import { OCIVaultSyncDestinationSection } from "./OCIVaultSyncDestinationSection"; import { RailwaySyncDestinationSection } from "./RailwaySyncDestinationSection"; @@ -148,6 +149,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Bitbucket: DestinationComponents = ; break; + case SecretSync.LaravelForge: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } 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 1d3f10188..61d1d8ccb 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -71,6 +71,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.DigitalOceanAppPlatform: case SecretSync.Netlify: case SecretSync.Bitbucket: + case SecretSync.LaravelForge: AdditionalSyncOptionsComponent = null; break; default: