From 5b765841b743462c7b2751c439403c2dfd8f5fce Mon Sep 17 00:00:00 2001 From: Piyush Gupta Date: Sat, 11 Oct 2025 03:08:36 +0530 Subject: [PATCH] feat: secret sync foundation --- backend/src/lib/api-docs/constants.ts | 5 + .../laravel-forge-connection-router.ts | 110 +++++++++++++++ .../routes/v1/secret-sync-routers/index.ts | 4 +- .../laravel-forge-sync-router.ts | 17 +++ .../secret-sync-routers/secret-sync-router.ts | 7 +- .../laravel-forge-connection-fns.ts | 126 ++++++++++++++++- .../laravel-forge-connection-service.ts | 64 ++++++++- .../laravel-forge-connection-types.ts | 40 +++++- .../secret-sync/laravel-forge/index.ts | 3 + .../laravel-forge-sync-constants.ts | 10 ++ .../laravel-forge-sync-schemas.ts | 48 +++++++ .../laravel-forge/laravel-forge-sync-types.ts | 41 ++++++ .../services/secret-sync/secret-sync-fns.ts | 4 +- .../services/secret-sync/secret-sync-types.ts | 14 +- .../LaravelForgeSyncFields.tsx | 132 ++++++++++++++++++ .../SecretSyncDestinationFields.tsx | 3 + .../laravel-forge-sync-destination-schema.ts | 15 ++ .../forms/schemas/secret-sync-schema.ts | 4 +- frontend/src/helpers/secretSyncs.ts | 7 +- .../api/appConnections/laravel-forge/index.ts | 2 + .../appConnections/laravel-forge/queries.tsx | 103 ++++++++++++++ .../api/appConnections/laravel-forge/types.ts | 15 ++ frontend/src/hooks/api/secretSyncs/enums.ts | 3 +- .../src/hooks/api/secretSyncs/types/index.ts | 4 +- .../secretSyncs/types/laravel-forge-sync.ts | 17 +++ 25 files changed, 783 insertions(+), 15 deletions(-) create mode 100644 backend/src/server/routes/v1/secret-sync-routers/laravel-forge-sync-router.ts create mode 100644 backend/src/services/secret-sync/laravel-forge/index.ts create mode 100644 backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts create mode 100644 backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts create mode 100644 backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts create mode 100644 frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/LaravelForgeSyncFields.tsx create mode 100644 frontend/src/components/secret-syncs/forms/schemas/laravel-forge-sync-destination-schema.ts create mode 100644 frontend/src/hooks/api/appConnections/laravel-forge/index.ts create mode 100644 frontend/src/hooks/api/appConnections/laravel-forge/queries.tsx create mode 100644 frontend/src/hooks/api/appConnections/laravel-forge/types.ts create mode 100644 frontend/src/hooks/api/secretSyncs/types/laravel-forge-sync.ts diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 269b283dd..141776b2b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2510,6 +2510,11 @@ 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.", + serverId: "The ID of the Laravel Forge server to sync secrets to.", + siteId: "The ID 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/laravel-forge-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/laravel-forge-connection-router.ts index ede855677..05054c429 100644 --- a/backend/src/server/routes/v1/app-connection-routers/laravel-forge-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/laravel-forge-connection-router.ts @@ -1,9 +1,14 @@ +import z from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { CreateLaravelForgeConnectionSchema, SanitizedLaravelForgeConnectionSchema, UpdateLaravelForgeConnectionSchema } from "@app/services/app-connection/laravel-forge"; +import { AuthMode } from "@app/services/auth/auth-type"; import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; @@ -15,4 +20,109 @@ export const registerLaravelForgeConnectionRouter = async (server: FastifyZodPro 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/laravel-forge/laravel-forge-connection-fns.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts index ec54b77ae..e63c659a7 100644 --- a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts @@ -1,12 +1,22 @@ +/* eslint-disable no-await-in-loop */ import { AxiosError } from "axios"; import { request } from "@app/lib/config/request"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { AppConnection } from "../app-connection-enums"; import { LaravelForgeConnectionMethod } from "./laravel-forge-connection-enums"; -import { TLaravelForgeConnectionConfig } from "./laravel-forge-connection-types"; +import { + TLaravelForgeConnection, + TLaravelForgeConnectionConfig, + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite, + TRawLaravelForgeOrganization, + TRawLaravelForgeServer, + TRawLaravelForgeSite +} from "./laravel-forge-connection-types"; export const getLaravelForgeConnectionListItem = () => { return { @@ -41,3 +51,115 @@ export const validateLaravelForgeConnectionCredentials = async (config: TLaravel 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-service.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts index 92a7e97e1..fc3c2bf80 100644 --- 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 @@ -1,7 +1,18 @@ +import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { TLaravelForgeConnection } from "./laravel-forge-connection-types"; +import { + listLaravelForgeOrganizations, + listLaravelForgeServers, + listLaravelForgeSites +} from "./laravel-forge-connection-fns"; +import { + TLaravelForgeConnection, + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite +} from "./laravel-forge-connection-types"; type TGetAppConnectionFunc = ( app: AppConnection, @@ -10,5 +21,54 @@ type TGetAppConnectionFunc = ( ) => Promise; export const laravelForgeConnectionService = (getAppConnection: TGetAppConnectionFunc) => { - console.log("laravelForgeConnectionService", getAppConnection); + 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 index 9ae3ce6e1..ad134ef3d 100644 --- 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 @@ -21,5 +21,43 @@ export type TLaravelForgeConnectionConfig = DiscriminativePick< TLaravelForgeConnectionInput, "method" | "app" | "credentials" > & { - orgId: string; + 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..b9e2e1fe3 --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/index.ts @@ -0,0 +1,3 @@ +export * from "./laravel-forge-sync-constants"; +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-schemas.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts new file mode 100644 index 000000000..b5b4474ae --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts @@ -0,0 +1,48 @@ +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 LaravelForgeSyncDestinationConfigSchema = z.object({ + orgSlug: z.string().min(1, "Org Slug is required").describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.orgSlug), + serverId: z.string().min(1, "Server ID is required").describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.serverId), + siteId: z.string().min(1, "Site ID is required").describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.siteId) +}); + +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..1de4f6070 --- /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 LaravelForgeSecret = { + description: string; + is_secret: boolean; + key: string; + source: "app" | "env"; + value: string; +}; + +export interface LaravelForgeApiSecret { + id: string; + key: string; + value: string; + type: string; + target: string[]; + customEnvironmentIds?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; + configurationId?: string; + system?: boolean; +} diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index a6faebd1e..80ffeb7ec 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -49,6 +49,7 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { LARAVEL_FORGE_SYNC_LIST_OPTION } from "./laravel-forge"; 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 +92,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 = () => { diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 478c44c92..9719f0a9e 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -117,6 +117,12 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { + TLaravelForgeSync, + TLaravelForgeSyncInput, + TLaravelForgeSyncListItem, + TLaravelForgeSyncWithCredentials +} from "./laravel-forge"; import { TNetlifySync, TNetlifySyncInput, TNetlifySyncListItem, TNetlifySyncWithCredentials } from "./netlify"; import { TRailwaySync, @@ -164,6 +170,7 @@ export type TSecretSync = | TTerraformCloudSync | TCamundaSync | TVercelSync + | TLaravelForgeSync | TWindmillSync | THCVaultSync | TTeamCitySync @@ -212,7 +219,8 @@ export type TSecretSyncWithCredentials = | TSupabaseSyncWithCredentials | TDigitalOceanAppPlatformSyncWithCredentials | TNetlifySyncWithCredentials - | TBitbucketSyncWithCredentials; + | TBitbucketSyncWithCredentials + | TLaravelForgeSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -244,7 +252,8 @@ export type TSecretSyncInput = | TSupabaseSyncInput | TDigitalOceanAppPlatformSyncInput | TNetlifySyncInput - | TBitbucketSyncInput; + | TBitbucketSyncInput + | TLaravelForgeSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -259,6 +268,7 @@ export type TSecretSyncListItem = | TTerraformCloudSyncListItem | TCamundaSyncListItem | TVercelSyncListItem + | TLaravelForgeSyncListItem | TWindmillSyncListItem | THCVaultSyncListItem | TTeamCitySyncListItem 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..33e7b80e8 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/LaravelForgeSyncFields.tsx @@ -0,0 +1,132 @@ +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", ""); + }; + + return ( + <> + + + ( + + org.slug === value) ?? null} + onChange={(option) => { + const selectedOrg = option as SingleValue; + onChange(selectedOrg?.slug ?? ""); + 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.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 ?? ""); + }} + 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/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..41f8ab51f --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/laravel-forge-sync-destination-schema.ts @@ -0,0 +1,15 @@ +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"), + serverId: z.string().trim().min(1, "Server ID required"), + siteId: z.string().trim().min(1, "Site ID 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/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/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..4940ab39f --- /dev/null +++ b/frontend/src/hooks/api/appConnections/laravel-forge/queries.tsx @@ -0,0 +1,103 @@ +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 ? { organizationSlug } : {}; + const { data } = await apiRequest.get( + `/api/v1/app-connections/laravel-forge/${connectionId}/servers`, + { params } + ); + + return data; + }, + enabled: Boolean(connectionId), + ...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: Record = {}; + if (organizationSlug) params.organizationSlug = organizationSlug; + if (serverId) params.serverId = serverId; + + const { data } = await apiRequest.get( + `/api/v1/app-connections/laravel-forge/${connectionId}/sites`, + { params } + ); + + return data; + }, + enabled: Boolean(connectionId), + ...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/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..68a2deb7b --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/laravel-forge-sync.ts @@ -0,0 +1,17 @@ +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; + serverId: string; + siteId: string; + }; + connection: { + app: AppConnection.LaravelForge; + name: string; + id: string; + }; +};