From 6c4d913f3c62d3c150d58b0beb4f261718cd4af3 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 7 Jul 2026 08:28:38 -0700 Subject: [PATCH] refactor(app): SecretsEditor edits refs, not values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployment form now manages only the wiring (ENV_VAR -> secret name) with a set/unset status badge and a link to the Secrets page for the value — it no longer fetches or inline-edits secret values. A deployment only ever stores ${secret:NAME}; values live in the backend and are managed in one place (the Secrets page). Removes the pre-vault holdover of pulling secret values into the deployment-editing surface. --- app/src/components/SecretsEditor.tsx | 296 +++++++++++---------------- 1 file changed, 114 insertions(+), 182 deletions(-) diff --git a/app/src/components/SecretsEditor.tsx b/app/src/components/SecretsEditor.tsx index 4e864af..4b3cd0a 100644 --- a/app/src/components/SecretsEditor.tsx +++ b/app/src/components/SecretsEditor.tsx @@ -1,210 +1,142 @@ -import { useEffect, useState } from "react" -import { Check, Copy, Eye, EyeOff, Loader2, Plus, Save, Trash2 } from "lucide-react" -import { apiClient } from "@/services/api/client" +import { useState } from "react" +import { Link } from "react-router-dom" +import { ExternalLink, Plus, Trash2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { useSecrets } from "@/services/api/hooks" interface SecretsEditorProps { - /** Current secret references: { ENV_VAR_NAME: "SECRET_FILE_NAME" } */ + /** Env var → secret name it references: { ENV_VAR_NAME: "SECRET_NAME" }. + * This edits the *wiring* only — a deployment stores `${secret:NAME}`, never the + * value. Values live in the backend and are managed on the Secrets page. */ secrets: Record onSecretsChange: (secrets: Record) => void } -interface SecretState { - value: string - original: string - visible: boolean - saving: boolean - saved: boolean - loaded: boolean - copied: boolean -} - -/** Copy text to the clipboard, returning whether it succeeded. - * - * `navigator.clipboard` only exists in a secure context (HTTPS or - * localhost). The dashboard is reached over plain HTTP across the LAN - * (e.g. from a phone), where it's undefined — so fall back to the legacy - * execCommand path, which works in insecure contexts. */ -async function writeClipboard(text: string): Promise { - if (navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(text) - return true - } catch { - // Fall through to the legacy path below. - } - } - try { - const ta = document.createElement("textarea") - ta.value = text - // Keep it out of view and unfocusable to the page layout. - ta.style.position = "fixed" - ta.style.opacity = "0" - document.body.appendChild(ta) - ta.select() - const ok = document.execCommand("copy") - document.body.removeChild(ta) - return ok - } catch { - return false - } -} - export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps) { - const [states, setStates] = useState>({}) + const { data: names } = useSecrets() + const known = new Set(names ?? []) + const [adding, setAdding] = useState(false) + const [newEnv, setNewEnv] = useState("") + const [newSecret, setNewSecret] = useState("") - // Load secret values when the secret list changes - useEffect(() => { - for (const [envKey, secretName] of Object.entries(secrets)) { - if (states[envKey]?.loaded) continue - setStates((prev) => ({ - ...prev, - [envKey]: { - value: "", original: "", visible: false, - saving: false, saved: false, loaded: false, copied: false, - }, - })) - apiClient - .get<{ value: string }>(`/secrets/${secretName}`) - .then((data) => { - setStates((prev) => ({ - ...prev, - [envKey]: { ...prev[envKey], value: data.value, original: data.value, loaded: true }, - })) - }) - .catch(() => { - setStates((prev) => ({ - ...prev, - [envKey]: { ...prev[envKey], loaded: true }, - })) - }) - } - }, [Object.keys(secrets).join(",")]) - - const handleSave = async (envKey: string) => { - const s = states[envKey] - const secretName = secrets[envKey] - if (!s || !secretName || s.value === s.original) return - - setStates((prev) => ({ ...prev, [envKey]: { ...prev[envKey], saving: true } })) - try { - await apiClient.put(`/secrets/${secretName}`, { value: s.value }) - setStates((prev) => ({ - ...prev, - [envKey]: { ...prev[envKey], saving: false, saved: true, original: s.value }, - })) - setTimeout(() => { - setStates((prev) => ({ ...prev, [envKey]: { ...prev[envKey], saved: false } })) - }, 2000) - } catch { - setStates((prev) => ({ ...prev, [envKey]: { ...prev[envKey], saving: false } })) - } - } - - const handleAdd = () => { - const envKey = prompt("Environment variable name (e.g. MY_API_KEY):") - if (!envKey) return - const secretName = prompt("Secret name (in the active backend — file or vault):", envKey) - if (!secretName) return + const setRef = (envKey: string, secretName: string) => onSecretsChange({ ...secrets, [envKey]: secretName }) - setStates((prev) => ({ - ...prev, - [envKey]: { - value: "", original: "", visible: true, - saving: false, saved: false, loaded: true, copied: false, - }, - })) - } - const handleCopy = async (envKey: string) => { - const s = states[envKey] - if (!s?.loaded) return - if (!(await writeClipboard(s.value))) return - setStates((prev) => ({ ...prev, [envKey]: { ...prev[envKey], copied: true } })) - setTimeout(() => { - setStates((prev) => ({ ...prev, [envKey]: { ...prev[envKey], copied: false } })) - }, 2000) - } - - const handleRemove = (envKey: string) => { + const removeRef = (envKey: string) => { const next = { ...secrets } delete next[envKey] onSecretsChange(next) - setStates((prev) => { - const n = { ...prev } - delete n[envKey] - return n - }) } + const add = () => { + if (!newEnv.trim() || !newSecret.trim()) return + onSecretsChange({ ...secrets, [newEnv.trim()]: newSecret.trim() }) + setNewEnv("") + setNewSecret("") + setAdding(false) + } + + const anyUnset = Object.values(secrets).some((n) => !known.has(n)) + return (
{Object.entries(secrets).map(([envKey, secretName]) => { - const s = states[envKey] - const dirty = s ? s.value !== s.original : false - + const isSet = known.has(secretName) return (
- + {envKey} -
- - setStates((prev) => ({ - ...prev, - [envKey]: { ...prev[envKey], value: e.target.value }, - })) - } - className="flex-1 min-w-0 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]" - /> - - - - -
+ + setRef(envKey, e.target.value)} + list="all-secret-names" + placeholder="SECRET_NAME" + className="flex-1 min-w-0 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none" + /> + + {isSet ? "set" : "unset"} + + + + +
) })} - + + + {(names ?? []).map((n) => ( + + + {adding ? ( +
+ setNewEnv(e.target.value)} + className="w-28 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none" + /> + + setNewSecret(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && add()} + className="flex-1 min-w-0 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none" + /> + + +
+ ) : ( + + )} + + {anyUnset && ( +

+ Unset refs won't resolve — set their value on the{" "} + + Secrets + {" "} + page. +

+ )}
) }