From 50acb791cd256f197f94c8cd26f98abf1c757ef0 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 1 Sep 2026 03:00:09 -0600 Subject: [PATCH 1/2] feat: bulk import secrets from vault providers Adds a modal to select and import multiple secrets from an assigned vault provider at once, instead of typing each ${{vault.x.y}} reference by hand. Existing keys are skipped by default and can be overridden per row; also adds an "Access all" shortcut to the vault provider assignment picker. --- .../environment/show-environment.tsx | 13 + .../application/environment/show.tsx | 2 + .../project/environment-variables.tsx | 13 +- .../projects/project-environment.tsx | 12 +- .../settings/vault/handle-vault-provider.tsx | 24 +- .../components/shared/vault-import-dialog.tsx | 307 ++++++++++++++++++ apps/dokploy/components/ui/secrets.tsx | 35 +- 7 files changed, 392 insertions(+), 14 deletions(-) create mode 100644 apps/dokploy/components/shared/vault-import-dialog.tsx diff --git a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx index b4bfff617..566dba3bb 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { z } from "zod"; import { CodeEditor } from "@/components/shared/code-editor"; import { useEnvCompletionSource } from "@/components/shared/env-autocomplete"; +import { VaultImportDialog } from "@/components/shared/vault-import-dialog"; import { Button } from "@/components/ui/button"; import { Card, @@ -197,6 +198,18 @@ export const ShowEnvironment = ({ id, type }: Props) => { name="environment" render={({ field }) => ( +
+ + form.setValue("environment", next, { + shouldDirty: true, + }) + } + /> +
{ } placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")} completionSource={completionSource} + projectId={data?.environment?.projectId} + environmentId={data?.environment?.environmentId} /> {data?.buildType === "dockerfile" && ( { name="env" render={({ field }) => ( - Environment variables +
+ Environment variables + + form.setValue("env", next, { shouldDirty: true }) + } + /> +
{ name="env" render={({ field }) => ( - Environment variables +
+ Environment variables + + form.setValue("env", next, { shouldDirty: true }) + } + /> +
{ )}
- Access +
+ Access + +
This provider can only be referenced from the selected projects. Pick environments to narrow it further — none selected means all diff --git a/apps/dokploy/components/shared/vault-import-dialog.tsx b/apps/dokploy/components/shared/vault-import-dialog.tsx new file mode 100644 index 000000000..43466256b --- /dev/null +++ b/apps/dokploy/components/shared/vault-import-dialog.tsx @@ -0,0 +1,307 @@ +import { DownloadIcon, Loader2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +interface Props { + projectId?: string; + environmentId?: string; + currentEnv: string; + onImport: (nextEnv: string) => void; +} + +const normalizeSecretName = (secretName: string) => { + const normalized = secretName + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toUpperCase(); + return /^[0-9]/.test(normalized) ? `_${normalized}` : normalized || "SECRET"; +}; + +const parseEnvKeys = (env: string) => { + const keys = new Set(); + for (const line of env.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue; + keys.add(trimmed.slice(0, trimmed.indexOf("=")).trim()); + } + return keys; +}; + +const setEnvValue = (env: string, key: string, ref: string) => { + const lines = env.split("\n"); + const line = `${key}=${ref}`; + const index = lines.findIndex((l) => { + const trimmed = l.trim(); + return ( + trimmed && + !trimmed.startsWith("#") && + trimmed.includes("=") && + trimmed.slice(0, trimmed.indexOf("=")).trim() === key + ); + }); + if (index >= 0) { + lines[index] = line; + return lines.join("\n"); + } + const withTrailingNewline = env.length > 0 && !env.endsWith("\n") ? "\n" : ""; + return `${env}${withTrailingNewline}${line}\n`; +}; + +export const VaultImportDialog = ({ + projectId, + environmentId, + currentEnv, + onImport, +}: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [vaultProviderId, setVaultProviderId] = useState(); + const [selected, setSelected] = useState>(new Set()); + const [keyOverrides, setKeyOverrides] = useState>({}); + + const { data: allProviders } = api.vaultProvider.all.useQuery(); + const providers = useMemo( + () => + (allProviders ?? []).filter((provider) => + provider.assignments?.some( + (assignment) => + assignment.projectId === projectId && + (assignment.environmentIds.length === 0 || + !environmentId || + assignment.environmentIds.includes(environmentId)), + ), + ), + [allProviders, projectId, environmentId], + ); + + const activeProviderId = vaultProviderId ?? providers[0]?.vaultProviderId; + const activeProvider = providers.find( + (p) => p.vaultProviderId === activeProviderId, + ); + + const { data: secretNames, isLoading } = + api.vaultProvider.listSecretNames.useQuery( + { + vaultProviderId: activeProviderId!, + projectId: projectId!, + environmentId, + }, + { enabled: isOpen && !!activeProviderId && !!projectId }, + ); + + const existingKeys = useMemo(() => parseEnvKeys(currentEnv), [currentEnv]); + + const rows = useMemo( + () => + (secretNames ?? []).map((secretName) => { + const defaultKey = normalizeSecretName(secretName); + const key = keyOverrides[secretName] ?? defaultKey; + return { + secretName, + key, + conflict: existingKeys.has(key), + }; + }), + [secretNames, keyOverrides, existingKeys], + ); + + const resetState = () => { + setSelected(new Set()); + setKeyOverrides({}); + }; + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (!open) { + setVaultProviderId(undefined); + resetState(); + } + }; + + const toggleRow = (secretName: string, checked: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(secretName); + else next.delete(secretName); + return next; + }); + }; + + const toggleAll = () => { + const selectableNonConflicting = rows.filter((r) => !r.conflict); + const allSelected = + selectableNonConflicting.length > 0 && + selectableNonConflicting.every((r) => selected.has(r.secretName)); + setSelected( + allSelected + ? new Set() + : new Set(selectableNonConflicting.map((r) => r.secretName)), + ); + }; + + const handleImport = () => { + if (!activeProvider) return; + let next = currentEnv; + for (const row of rows) { + if (!selected.has(row.secretName)) continue; + const ref = `\${{vault.${activeProvider.name}.${row.secretName}}}`; + next = setEnvValue(next, row.key, ref); + } + onImport(next); + setIsOpen(false); + setVaultProviderId(undefined); + resetState(); + }; + + if (!projectId || providers.length === 0) { + return null; + } + + return ( + + + + + + + Import secrets from vault + + Select the secrets to import as{" "} + {"${{vault..}}"} references. Secrets + already defined below are skipped unless you check them explicitly. + + + +
+
+ + +
+ + {isLoading ? ( +
+ + Loading secrets... +
+ ) : rows.length === 0 ? ( +
+ No secrets found for this provider. +
+ ) : ( + <> +
+ + + {selected.size} selected + +
+ +
+ {rows.map((row) => ( +
+ + toggleRow(row.secretName, checked === true) + } + /> +
+ + {row.secretName} + + + setKeyOverrides((prev) => ({ + ...prev, + [row.secretName]: e.target.value + .toUpperCase() + .replace(/[^A-Z0-9_]/g, "_"), + })) + } + className="h-8 font-mono text-sm" + /> +
+ {row.conflict && ( + already exists + )} +
+ ))} +
+
+ + )} +
+ + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/ui/secrets.tsx b/apps/dokploy/components/ui/secrets.tsx index 61b7f8a83..a9a8c06e6 100644 --- a/apps/dokploy/components/ui/secrets.tsx +++ b/apps/dokploy/components/ui/secrets.tsx @@ -3,6 +3,7 @@ import { EyeIcon, EyeOffIcon } from "lucide-react"; import { type CSSProperties, type ReactNode, useState } from "react"; import { useFormContext } from "react-hook-form"; import { CodeEditor } from "@/components/shared/code-editor"; +import { VaultImportDialog } from "@/components/shared/vault-import-dialog"; import { CardContent, CardDescription, @@ -23,6 +24,8 @@ interface Props { description: ReactNode; placeholder: string; completionSource?: CompletionSource; + projectId?: string; + environmentId?: string; } export const Secrets = (props: Props) => { @@ -37,17 +40,27 @@ export const Secrets = (props: Props) => { {props.description}
- - {isVisible ? ( - - ) : ( - - )} - +
+ + form.setValue(props.name, next, { shouldDirty: true }) + } + /> + + {isVisible ? ( + + ) : ( + + )} + +
Date: Tue, 1 Sep 2026 03:05:38 -0600 Subject: [PATCH 2/2] test: fix stale traefik remote-write test expectation writeTraefikConfigRemote moved from an execAsyncRemote echo command to writeFileRemote (SFTP) a while back; the test still mocked the old execAsyncRemote path and asserted on an echo command that no longer runs. --- .../traefik/write-app-traefik-config.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts index 90c1ba60a..5b1babb2a 100644 --- a/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts +++ b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ execAsyncRemote: vi.fn(), + writeFileRemote: vi.fn(), })); vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => { @@ -16,6 +17,7 @@ vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => { return { ...actual, execAsyncRemote: mocks.execAsyncRemote, + writeFileRemote: mocks.writeFileRemote, }; }); @@ -91,7 +93,7 @@ describe("writeAppTraefikConfig", () => { }); it("writes the remote file when routers/services are present", async () => { - mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.writeFileRemote.mockResolvedValue(undefined); await writeAppTraefikConfig( { @@ -109,8 +111,11 @@ describe("writeAppTraefikConfig", () => { "server-id", ); - expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); - const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? []; - expect(command).toMatch(/^echo /); + expect(mocks.writeFileRemote).toHaveBeenCalledOnce(); + const [serverId, remotePath, content] = + mocks.writeFileRemote.mock.calls[0] ?? []; + expect(serverId).toBe("server-id"); + expect(remotePath).toContain("with-domain-app.yml"); + expect(content).toContain("with-domain-app-router-1"); }); });