From b8822a1fde29b5dfffafc690efb52f918fe114d9 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 10 Aug 2026 11:14:55 -0600 Subject: [PATCH] feat: integrate vault provider management and environment variable resolution - Added support for managing various vault providers (HashiCorp, AWS, Azure, Doppler, Infisical) in the dashboard. - Implemented environment variable resolution using vault references, allowing seamless integration of secrets into application environments. - Enhanced UI components to display and manage vault providers effectively. - Introduced tests for vault reference resolution and environment variable preparation. - Updated related components to utilize new vault management features. --- apps/dokploy/__test__/env/vault.test.ts | 316 +++++++ .../enterprise-only-resources.test.ts | 1 + .../environment/show-environment.tsx | 6 + .../application/environment/show.tsx | 9 + .../project/environment-variables.tsx | 9 +- .../projects/project-environment.tsx | 6 + .../settings/vault/handle-vault-provider.tsx | 837 ++++++++++++++++++ .../settings/vault/show-vault-providers.tsx | 154 ++++ .../components/icons/vault-provider-icons.tsx | 544 ++++++++++++ apps/dokploy/components/layouts/side.tsx | 8 + apps/dokploy/components/shared/analytics.tsx | 86 ++ .../dokploy/components/shared/code-editor.tsx | 13 +- .../components/shared/env-autocomplete.ts | 166 ++++ apps/dokploy/components/ui/secrets.tsx | 3 + apps/dokploy/lib/analytics.ts | 28 + .../pages/dashboard/settings/secrets.tsx | 53 ++ apps/dokploy/server/api/root.ts | 2 + .../server/api/routers/vault-provider.ts | 147 +++ apps/dokploy/server/wss/authorize.ts | 1 + packages/server/package.json | 1 + packages/server/src/db/schema/audit-log.ts | 3 +- packages/server/src/db/schema/index.ts | 1 + .../server/src/db/schema/vault-provider.ts | 128 +++ packages/server/src/index.ts | 2 + packages/server/src/lib/access-control.ts | 6 + packages/server/src/services/rollbacks.ts | 15 +- .../server/src/services/vault-provider.ts | 200 +++++ packages/server/src/utils/builders/compose.ts | 4 +- packages/server/src/utils/builders/index.ts | 7 +- packages/server/src/utils/databases/libsql.ts | 4 +- .../server/src/utils/databases/mariadb.ts | 4 +- packages/server/src/utils/databases/mongo.ts | 4 +- packages/server/src/utils/databases/mysql.ts | 4 +- .../server/src/utils/databases/postgres.ts | 4 +- packages/server/src/utils/databases/redis.ts | 4 +- packages/server/src/utils/docker/utils.ts | 7 + packages/server/src/utils/vault/aws.ts | 107 +++ packages/server/src/utils/vault/azure.ts | 123 +++ packages/server/src/utils/vault/doppler.ts | 61 ++ packages/server/src/utils/vault/hashicorp.ts | 140 +++ packages/server/src/utils/vault/index.ts | Bin 0 -> 3684 bytes packages/server/src/utils/vault/infisical.ts | 87 ++ packages/server/src/utils/vault/types.ts | 18 + pnpm-lock.yaml | 274 ++++++ 44 files changed, 3577 insertions(+), 20 deletions(-) create mode 100644 apps/dokploy/__test__/env/vault.test.ts create mode 100644 apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx create mode 100644 apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx create mode 100644 apps/dokploy/components/icons/vault-provider-icons.tsx create mode 100644 apps/dokploy/components/shared/analytics.tsx create mode 100644 apps/dokploy/components/shared/env-autocomplete.ts create mode 100644 apps/dokploy/lib/analytics.ts create mode 100644 apps/dokploy/pages/dashboard/settings/secrets.tsx create mode 100644 apps/dokploy/server/api/routers/vault-provider.ts create mode 100644 packages/server/src/db/schema/vault-provider.ts create mode 100644 packages/server/src/services/vault-provider.ts create mode 100644 packages/server/src/utils/vault/aws.ts create mode 100644 packages/server/src/utils/vault/azure.ts create mode 100644 packages/server/src/utils/vault/doppler.ts create mode 100644 packages/server/src/utils/vault/hashicorp.ts create mode 100644 packages/server/src/utils/vault/index.ts create mode 100644 packages/server/src/utils/vault/infisical.ts create mode 100644 packages/server/src/utils/vault/types.ts diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts new file mode 100644 index 000000000..8465e4ae5 --- /dev/null +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -0,0 +1,316 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findMany = vi.fn(); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + vaultProvider: { + findMany: (...args: unknown[]) => findMany(...args), + }, + }, + }, +})); + +import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils"; +import { + resolveVaultReferences, + withResolvedVaultRefs, +} from "@dokploy/server/utils/vault"; +import { azureClient } from "@dokploy/server/utils/vault/azure"; +import { dopplerClient } from "@dokploy/server/utils/vault/doppler"; +import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + json: async () => body, + }) as Response; + +beforeEach(() => { + findMany.mockReset(); + mockFetch.mockReset(); +}); + +describe("resolveVaultReferences", () => { + it("returns input untouched and skips the db when no refs are present", async () => { + const env = "FOO=bar\nBAZ=${{project.QUX}}"; + const result = await resolveVaultReferences(env, "org-1"); + expect(result).toBe(env); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("returns null/empty inputs unchanged", async () => { + expect(await resolveVaultReferences(null, "org-1")).toBeNull(); + expect(await resolveVaultReferences("", "org-1")).toBe(""); + }); + + it("throws when refs exist but no organization context is given", async () => { + await expect( + resolveVaultReferences("FOO=${{vault.prod.SECRET}}"), + ).rejects.toThrow("not supported in this context"); + }); + + it("throws for an unknown provider", async () => { + findMany.mockResolvedValue([]); + await expect( + resolveVaultReferences("FOO=${{vault.missing.SECRET}}", "org-1"), + ).rejects.toThrow('Vault provider "missing" not found'); + }); + + it("resolves refs through a doppler provider", async () => { + findMany.mockResolvedValue([ + { + name: "doppler-prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + }, + ]); + mockFetch.mockResolvedValue( + jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }), + ); + + const result = await resolveVaultReferences( + "DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}", + "org-1", + ); + + expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws when the secret is missing in the provider", async () => { + findMany.mockResolvedValue([ + { + name: "doppler-prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" })); + + await expect( + resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", "org-1"), + ).rejects.toThrow('secret "MISSING" not found'); + }); +}); + +describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => { + it("resolves entity env sources so sync interpolation sees real values", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" })); + + const entity = { + env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db", + environment: { + env: null, + project: { + env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}", + organizationId: "org-1", + }, + }, + }; + + const resolved = await withResolvedVaultRefs(entity); + const prepared = prepareEnvironmentVariables( + resolved.env, + resolved.environment.project.env, + resolved.environment.env, + ); + + expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]); + }); + + it("resolves buildArgs and buildSecrets when present", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" })); + + const resolved = await withResolvedVaultRefs({ + env: "FOO=bar", + buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}", + buildSecrets: null, + environment: { + env: null, + project: { env: null, organizationId: "org-1" }, + }, + }); + + expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123"); + expect(resolved.buildSecrets).toBeNull(); + expect(resolved.env).toBe("FOO=bar"); + }); + + it("prepareEnvironmentVariables throws on unresolved vault refs", () => { + expect(() => + prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""), + ).toThrow("Unresolved vault reference"); + }); + + it("keeps working without vault refs", () => { + const prepared = prepareEnvironmentVariables( + "FOO=${{project.BAR}}", + "BAR=baz", + ); + expect(prepared).toEqual(["FOO=baz"]); + expect(findMany).not.toHaveBeenCalled(); + }); +}); + +describe("hashicorp client", () => { + const config = { + providerType: "hashicorp" as const, + url: "https://vault.example.com", + token: "hvs.token", + mount: "secret", + }; + + it("rejects refs without a field", async () => { + await expect( + hashicorpClient.getSecrets(config, ["myapp/prod"]), + ).rejects.toThrow("expected format :"); + }); + + it("groups refs by path and picks fields from KV v2 data", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }), + ); + + const result = await hashicorpClient.getSecrets(config, [ + "myapp/prod:USER", + "myapp/prod:PASS", + ]); + + expect(result).toEqual({ + "myapp/prod:USER": "admin", + "myapp/prod:PASS": "pw", + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://vault.example.com/v1/secret/data/myapp/prod", + ); + }); + + it("throws when a field is missing", async () => { + mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } })); + await expect( + hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]), + ).rejects.toThrow('field "MISSING" not found'); + }); + + it("lists full path:field refs by walking directories", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("/metadata?list=true")) { + return jsonResponse({ data: { keys: ["myapp/", "shared"] } }); + } + if (url.includes("/metadata/myapp?list=true")) { + return jsonResponse({ data: { keys: ["prod"] } }); + } + if (url.includes("/data/myapp/prod")) { + return jsonResponse({ + data: { data: { API_KEY: "a", DB_PASSWORD: "b" } }, + }); + } + if (url.includes("/data/shared")) { + return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } }); + } + return jsonResponse({}, false, 404); + }); + + const names = await hashicorpClient.listSecretNames?.(config); + + expect(names).toEqual([ + "myapp/prod:API_KEY", + "myapp/prod:DB_PASSWORD", + "shared:STRIPE_KEY", + ]); + }); +}); + +describe("azure client", () => { + const config = { + providerType: "azure" as const, + vaultUri: "https://my-vault.vault.azure.net", + tenantId: "tenant-1", + clientId: "client-1", + clientSecret: "secret-1", + }; + + it("authenticates and reads secrets by name", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("login.microsoftonline.com/tenant-1")) { + return jsonResponse({ access_token: "azure-token" }); + } + if (url.includes("/secrets/db-password?")) { + return jsonResponse({ value: "azure-pw" }); + } + return jsonResponse({}, false, 404); + }); + + const result = await azureClient.getSecrets(config, ["db-password"]); + + expect(result).toEqual({ "db-password": "azure-pw" }); + }); + + it("throws a clear error for missing secrets", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("login.microsoftonline.com")) { + return jsonResponse({ access_token: "azure-token" }); + } + return jsonResponse({}, false, 404); + }); + + await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow( + 'secret "nope" not found', + ); + }); + + it("lists secret names from paged results", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("login.microsoftonline.com")) { + return jsonResponse({ access_token: "azure-token" }); + } + if (url.includes("skiptoken")) { + return jsonResponse({ + value: [{ id: `${config.vaultUri}/secrets/second` }], + nextLink: null, + }); + } + return jsonResponse({ + value: [{ id: `${config.vaultUri}/secrets/first` }], + nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`, + }); + }); + + const names = await azureClient.listSecretNames?.(config); + + expect(names).toEqual(["first", "second"]); + }); +}); + +describe("doppler client", () => { + it("propagates auth errors with the status code", async () => { + mockFetch.mockResolvedValue(jsonResponse({}, false, 401)); + await expect( + dopplerClient.getSecrets( + { providerType: "doppler", serviceToken: "bad" }, + ["FOO"], + ), + ).rejects.toThrow("status 401"); + }); +}); diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts index bb6f5f18b..85cd0821f 100644 --- a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -39,6 +39,7 @@ const ENTERPRISE_RESOURCES = [ "logs", "monitoring", "auditLog", + "vaultProvider", ]; describe("enterpriseOnlyResources set", () => { diff --git a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx index 873fbd1e6..efd78fe69 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -5,6 +5,7 @@ import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { CodeEditor } from "@/components/shared/code-editor"; +import { useEnvCompletionSource } from "@/components/shared/env-autocomplete"; import { Button } from "@/components/ui/button"; import { Card, @@ -58,6 +59,10 @@ export const ShowEnvironment = ({ id, type }: Props) => { ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const [isEnvVisible, setIsEnvVisible] = useState(true); + const completionSource = useEnvCompletionSource({ + projectEnv: data?.environment?.project?.env, + environmentEnv: data?.environment?.env, + }); const mutationMap = { compose: () => api.compose.saveEnvironment.useMutation(), @@ -198,6 +203,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { } as CSSProperties } language="properties" + completionSource={completionSource} disabled={isEnvVisible} className="font-mono" wrapperClassName="compose-file-editor" diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 378a871ad..05f4fd491 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { useEnvCompletionSource } from "@/components/shared/env-autocomplete"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { @@ -45,6 +46,11 @@ export const ShowEnvironment = ({ applicationId }: Props) => { }, ); + const completionSource = useEnvCompletionSource({ + projectEnv: data?.environment?.project?.env, + environmentEnv: data?.environment?.env, + }); + const form = useForm({ defaultValues: { env: "", @@ -142,6 +148,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { } placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")} + completionSource={completionSource} /> {data?.buildType === "dockerfile" && ( { } placeholder="NPM_TOKEN=xyz" + completionSource={completionSource} /> )} {data?.buildType === "dockerfile" && ( @@ -185,6 +193,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { } placeholder="NPM_TOKEN=xyz" + completionSource={completionSource} /> )} {data?.buildType === "dockerfile" && ( diff --git a/apps/dokploy/components/dashboard/project/environment-variables.tsx b/apps/dokploy/components/dashboard/project/environment-variables.tsx index 0f2c4f4ab..3e56ab217 100644 --- a/apps/dokploy/components/dashboard/project/environment-variables.tsx +++ b/apps/dokploy/components/dashboard/project/environment-variables.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; +import { useEnvCompletionSource } from "@/components/shared/env-autocomplete"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -55,6 +56,9 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => { }, ); + const completionSource = useEnvCompletionSource({ + includeShared: false, + }); const form = useForm({ defaultValues: { env: data?.env ?? "", @@ -134,7 +138,9 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => { Use this syntax to reference environment-level variables in your service environments:{" "} - API_URL=${"{{environment.API_URL}}"} + API_URL=${"{{environment.API_URL}}"}. You can also + reference secrets from a configured vault provider:{" "} + DB_URL=${"{{vault..}}"}
@@ -151,6 +157,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => { Environment variables { }, ); + const completionSource = useEnvCompletionSource({ + includeShared: false, + }); const form = useForm({ defaultValues: { env: data?.env ?? "", @@ -77,6 +81,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { .then(() => { toast.success("Project env updated successfully"); utils.project.all.invalidate(); + utils.project.one.invalidate({ projectId }); }) .catch(() => { toast.error("Error updating the env"); @@ -149,6 +154,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { Environment variables .}})", + }), + providerType: z.enum(["hashicorp", "infisical", "aws", "doppler", "azure"]), + url: z.string(), + token: z.string(), + namespace: z.string(), + mount: z.string(), + siteUrl: z.string(), + clientId: z.string(), + clientSecret: z.string(), + projectId: z.string(), + environmentSlug: z.string(), + secretPath: z.string(), + region: z.string(), + accessKeyId: z.string(), + secretAccessKey: z.string(), + serviceToken: z.string(), + awsEndpoint: z.string(), + vaultUri: z.string(), + tenantId: z.string(), + azureClientId: z.string(), + azureClientSecret: z.string(), + dopplerProject: z.string(), + dopplerConfig: z.string(), + }) + .superRefine((data, ctx) => { + const isValidUrl = (value: string) => { + try { + new URL(value); + return true; + } catch { + return false; + } + }; + + if ( + data.providerType === "hashicorp" && + data.url && + !isValidUrl(data.url) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid URL (e.g. https://vault.example.com:8200)", + path: ["url"], + }); + } + if ( + data.providerType === "azure" && + data.vaultUri && + !isValidUrl(data.vaultUri) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid URL (e.g. https://my-vault.vault.azure.net)", + path: ["vaultUri"], + }); + } + if ( + data.providerType === "aws" && + data.awsEndpoint && + !isValidUrl(data.awsEndpoint) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid URL", + path: ["awsEndpoint"], + }); + } + if ( + data.providerType === "infisical" && + data.siteUrl && + !isValidUrl(data.siteUrl) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid URL (e.g. https://app.infisical.com)", + path: ["siteUrl"], + }); + } + + const required: Partial< + Record + > = { + hashicorp: [ + ["url", "Vault URL is required"], + ["token", "Token is required"], + ["mount", "Mount is required"], + ], + infisical: [ + ["siteUrl", "Site URL is required"], + ["clientId", "Client ID is required"], + ["clientSecret", "Client Secret is required"], + ["projectId", "Project ID is required"], + ["environmentSlug", "Environment is required"], + ], + aws: [ + ["region", "Region is required"], + ["accessKeyId", "Access Key ID is required"], + ["secretAccessKey", "Secret Access Key is required"], + ], + doppler: [["serviceToken", "Service Token is required"]], + azure: [ + ["vaultUri", "Vault URI is required"], + ["tenantId", "Tenant ID is required"], + ["azureClientId", "Client ID is required"], + ["azureClientSecret", "Client Secret is required"], + ], + }; + + for (const [field, message] of required[data.providerType] ?? []) { + if (!data[field]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message, + path: [field], + }); + } + } + + if ( + data.providerType === "doppler" && + data.serviceToken && + !data.serviceToken.startsWith("dp.st.") && + data.serviceToken !== "********" + ) { + for (const field of ["dopplerProject", "dopplerConfig"] as const) { + if (!data[field]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Required for personal/CLI tokens (dp.pt. / dp.ct.)", + path: [field], + }); + } + } + } + }); + +type VaultProviderForm = z.infer; + +const defaultValues: VaultProviderForm = { + name: "", + providerType: "hashicorp", + url: "", + token: "", + namespace: "", + mount: "secret", + siteUrl: "https://app.infisical.com", + clientId: "", + clientSecret: "", + projectId: "", + environmentSlug: "", + secretPath: "/", + region: "", + accessKeyId: "", + secretAccessKey: "", + serviceToken: "", + awsEndpoint: "", + vaultUri: "", + tenantId: "", + azureClientId: "", + azureClientSecret: "", + dopplerProject: "", + dopplerConfig: "", +}; + +const buildConfig = (data: VaultProviderForm) => { + switch (data.providerType) { + case "hashicorp": + return { + providerType: "hashicorp" as const, + url: data.url, + token: data.token, + namespace: data.namespace || undefined, + mount: data.mount || "secret", + }; + case "infisical": + return { + providerType: "infisical" as const, + siteUrl: data.siteUrl || "https://app.infisical.com", + clientId: data.clientId, + clientSecret: data.clientSecret, + projectId: data.projectId, + environmentSlug: data.environmentSlug, + secretPath: data.secretPath || "/", + }; + case "aws": + return { + providerType: "aws" as const, + region: data.region, + accessKeyId: data.accessKeyId, + secretAccessKey: data.secretAccessKey, + endpoint: data.awsEndpoint || undefined, + }; + case "doppler": + return { + providerType: "doppler" as const, + serviceToken: data.serviceToken, + project: data.dopplerProject || undefined, + config: data.dopplerConfig || undefined, + }; + case "azure": + return { + providerType: "azure" as const, + vaultUri: data.vaultUri, + tenantId: data.tenantId, + clientId: data.azureClientId, + clientSecret: data.azureClientSecret, + }; + } +}; + +const extractErrorMessage = (err: unknown) => { + if (!(err instanceof Error)) return undefined; + try { + const issues = JSON.parse(err.message) as { message?: string }[]; + if (Array.isArray(issues)) { + return issues + .map((issue) => issue.message) + .filter(Boolean) + .join(", "); + } + } catch {} + return err.message; +}; + +interface Props { + vaultProviderId?: string; +} + +export const HandleVaultProvider = ({ vaultProviderId }: Props) => { + const utils = api.useUtils(); + const [isOpen, setIsOpen] = useState(false); + + const { data: provider } = api.vaultProvider.one.useQuery( + { vaultProviderId: vaultProviderId || "" }, + { enabled: !!vaultProviderId && isOpen }, + ); + + const { mutateAsync, isPending, error, isError } = vaultProviderId + ? api.vaultProvider.update.useMutation() + : api.vaultProvider.create.useMutation(); + + const { mutateAsync: testConnection, isPending: isTesting } = + api.vaultProvider.testConnection.useMutation(); + + const form = useForm({ + defaultValues, + resolver: zodResolver(VaultProviderSchema), + }); + + const providerType = form.watch("providerType"); + + useEffect(() => { + if (provider) { + form.reset({ + ...defaultValues, + name: provider.name, + providerType: provider.config.providerType, + ...(provider.config.providerType === "hashicorp" && { + url: provider.config.url, + token: provider.config.token, + namespace: provider.config.namespace ?? "", + mount: provider.config.mount, + }), + ...(provider.config.providerType === "infisical" && { + siteUrl: provider.config.siteUrl, + clientId: provider.config.clientId, + clientSecret: provider.config.clientSecret, + projectId: provider.config.projectId, + environmentSlug: provider.config.environmentSlug, + secretPath: provider.config.secretPath, + }), + ...(provider.config.providerType === "aws" && { + region: provider.config.region, + accessKeyId: provider.config.accessKeyId, + secretAccessKey: provider.config.secretAccessKey, + awsEndpoint: provider.config.endpoint ?? "", + }), + ...(provider.config.providerType === "doppler" && { + serviceToken: provider.config.serviceToken, + dopplerProject: provider.config.project ?? "", + dopplerConfig: provider.config.config ?? "", + }), + ...(provider.config.providerType === "azure" && { + vaultUri: provider.config.vaultUri, + tenantId: provider.config.tenantId, + azureClientId: provider.config.clientId, + azureClientSecret: provider.config.clientSecret, + }), + }); + } else if (!vaultProviderId) { + form.reset(defaultValues); + } + }, [provider, vaultProviderId, form, isOpen]); + + const onSubmit = async (data: VaultProviderForm) => { + const payload: any = { + name: data.name, + config: buildConfig(data), + ...(vaultProviderId && { vaultProviderId }), + }; + await mutateAsync(payload) + .then(() => { + toast.success( + vaultProviderId ? "Vault provider updated" : "Vault provider created", + ); + utils.vaultProvider.all.invalidate(); + setIsOpen(false); + }) + .catch(() => {}); + }; + + const onTestConnection = async () => { + const isValid = await form.trigger(); + if (!isValid) { + return; + } + const data = form.getValues(); + await testConnection({ + config: buildConfig(data), + ...(vaultProviderId && { vaultProviderId }), + }) + .then(() => { + toast.success("Connection successful"); + }) + .catch((err) => { + toast.error("Connection failed", { + description: extractErrorMessage(err), + }); + }); + }; + + return ( + + + {vaultProviderId ? ( + + ) : ( + + )} + + + + + {vaultProviderId ? "Update Vault Provider" : "Add Vault Provider"} + + + Reference secrets in your environment variables with{" "} + {"${{vault..}}"}. Secrets are fetched at + deploy time and never stored in Dokploy. + + + {isError && {error?.message}} +
+ + ( + + Name + + + + + + )} + /> + ( + + Provider + + + + )} + /> + + {providerType === "hashicorp" && ( + <> + ( + + Vault URL + + + + + + )} + /> + ( + + Token + + + + + + )} + /> +
+ ( + + KV Mount + + + + + + )} + /> + ( + + Namespace (optional) + + + + + + )} + /> +
+ + Reference format:{" "} + {"${{vault..path/to/secret:FIELD}}"} + + + )} + + {providerType === "infisical" && ( + <> + ( + + Site URL + + + + + + )} + /> +
+ ( + + Client ID + + + + + + )} + /> + ( + + Client Secret + + + + + + )} + /> +
+
+ ( + + Project ID + + + + + + )} + /> + ( + + Environment + + + + + + )} + /> +
+ ( + + Secret Path + + + + + + )} + /> + + )} + + {providerType === "aws" && ( + <> + ( + + Region + + + + + + )} + /> + ( + + Access Key ID + + + + + + )} + /> + ( + + Secret Access Key + + + + + + )} + /> + ( + + Endpoint (optional) + + + + + Custom endpoint for VPC endpoints or API-compatible + emulators + + + + )} + /> + + Reference format:{" "} + {"${{vault..secret-name}}"} or{" "} + {"${{vault..secret-name:field}}"} for JSON + secrets + + + )} + + {providerType === "azure" && ( + <> + ( + + Vault URI + + + + + + )} + /> + ( + + Tenant ID + + + + + + )} + /> +
+ ( + + Client ID + + + + + + )} + /> + ( + + Client Secret + + + + + + )} + /> +
+ + App Registration with the "Key Vault Secrets User" role on + the vault. Reference format:{" "} + {"${{vault..secret-name}}"} + + + )} + + {providerType === "doppler" && ( + <> + ( + + Token + + + + + Service tokens (dp.st.) are recommended: read-only and + scoped to a single project + config + + + + )} + /> +
+ ( + + Project (optional) + + + + + + )} + /> + ( + + Config (optional) + + + + + + )} + /> +
+ + Only needed for personal (dp.pt.) or CLI (dp.ct.) tokens — + service tokens already carry them + + + )} + + + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx b/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx new file mode 100644 index 000000000..43157f031 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx @@ -0,0 +1,154 @@ +import { Loader2, Trash2, Vault } from "lucide-react"; +import { toast } from "sonner"; +import { vaultProviderIcons } from "@/components/icons/vault-provider-icons"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/utils/api"; +import { HandleVaultProvider } from "./handle-vault-provider"; + +const providerLabels: Record = { + hashicorp: "HashiCorp Vault", + infisical: "Infisical", + aws: "AWS Secrets Manager", + doppler: "Doppler", + azure: "Azure Key Vault", +}; + +export const ShowVaultProviders = () => { + const { mutateAsync, isPending: isRemoving } = + api.vaultProvider.remove.useMutation(); + const { data, isPending, refetch } = api.vaultProvider.all.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); + + return ( +
+ +
+ + + + Secrets Providers + + + Connect external secret managers and reference their secrets in + environment variables with{" "} + {"${{vault..}}"} + + + + {isPending ? ( +
+ Loading... + +
+ ) : ( + <> + {data?.length === 0 ? ( +
+ + + You don't have any secrets providers configured + + {permissions?.vaultProvider.create && ( + + )} +
+ ) : ( +
+
+ {data?.map((provider) => { + const ProviderIcon = + vaultProviderIcons[provider.providerType]; + return ( +
+
+
+ +
+ + {provider.name} + +
+ + {providerLabels[provider.providerType] ?? + provider.providerType} + + + {"${{vault." + provider.name + ".…}}"} + +
+
+
+ +
+ {permissions?.vaultProvider.update && ( + + )} + {permissions?.vaultProvider.delete && ( + { + await mutateAsync({ + vaultProviderId: + provider.vaultProviderId, + }) + .then(() => { + toast.success( + "Secrets provider deleted", + ); + refetch(); + }) + .catch(() => { + toast.error( + "Error deleting the secrets provider", + ); + }); + }} + > + + + )} +
+
+
+ ); + })} +
+ + {permissions?.vaultProvider.create && ( +
+ +
+ )} +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/icons/vault-provider-icons.tsx b/apps/dokploy/components/icons/vault-provider-icons.tsx new file mode 100644 index 000000000..440f54529 --- /dev/null +++ b/apps/dokploy/components/icons/vault-provider-icons.tsx @@ -0,0 +1,544 @@ +interface Props { + className?: string; +} + +export const HashicorpVaultIcon = ({ className }: Props) => ( + + + +); + +export const InfisicalIcon = ({ className }: Props) => ( + + + +); + +export const AwsIcon = ({ className }: Props) => ( + + + + + +); + +export const DopplerIcon = ({ className }: Props) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const AzureIcon = ({ className }: Props) => ( + + + + + + + + + + + + + + + + + + + + + + + +); + +export const vaultProviderIcons = { + hashicorp: HashicorpVaultIcon, + infisical: InfisicalIcon, + aws: AwsIcon, + doppler: DopplerIcon, + azure: AzureIcon, +} as const; diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 3cb9e29a6..1ef89d203 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -35,6 +35,7 @@ import { Trash2, User, Users, + Vault, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -350,6 +351,13 @@ const MENU: Menu = { icon: Package, isEnabled: ({ permissions }) => !!permissions?.registry.read, }, + { + isSingle: true, + title: "Secrets", + url: "/dashboard/settings/secrets", + icon: Vault, + isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create, + }, { isSingle: true, title: "S3 Destinations", diff --git a/apps/dokploy/components/shared/analytics.tsx b/apps/dokploy/components/shared/analytics.tsx new file mode 100644 index 000000000..b54fc96bf --- /dev/null +++ b/apps/dokploy/components/shared/analytics.tsx @@ -0,0 +1,86 @@ +import { useRouter } from "next/router"; +import Script from "next/script"; +import { useEffect } from "react"; +import { GTM_ID, pushToDataLayer } from "@/lib/analytics"; +import { api } from "@/utils/api"; + +/** + * Loads Google Tag Manager and the HubSpot marketing tag on the cloud + * version only, and translates tracking query params appended by the + * auth/billing flows (?signup=..., ?subscription=new) into dataLayer events. + */ +export const Analytics = () => { + const router = useRouter(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + + useEffect(() => { + if (!isCloud || !router.isReady) { + return; + } + const { signup, subscription, tier, ...rest } = router.query; + + if (typeof signup === "string" && signup.length > 0) { + pushToDataLayer("sign_up", { method: signup }); + } + if (subscription === "new") { + pushToDataLayer("new_subscription", { + ...(typeof tier === "string" ? { tier } : {}), + }); + } + if (signup !== undefined || subscription !== undefined) { + router.replace({ pathname: router.pathname, query: rest }, undefined, { + shallow: true, + }); + } + }, [isCloud, router.isReady, router.query, router.pathname, router.replace]); + + if (!isCloud) { + return null; + } + + return ( + <> + +