refactor(app): SecretsEditor edits refs, not values

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.
This commit is contained in:
2026-07-07 08:28:38 -07:00
parent 1bc41ddd2d
commit 6c4d913f3c

View File

@@ -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<string, string>
onSecretsChange: (secrets: Record<string, string>) => 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<boolean> {
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<Record<string, SecretState>>({})
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 (
<div className="space-y-2">
{Object.entries(secrets).map(([envKey, secretName]) => {
const s = states[envKey]
const dirty = s ? s.value !== s.original : false
const isSet = known.has(secretName)
return (
<div key={envKey} className="flex items-center gap-2">
<span className="w-24 sm:w-56 shrink-0 text-xs font-mono text-[var(--muted)] truncate" title={`${envKey}${secretName}`}>
<span
className="w-24 sm:w-48 shrink-0 truncate font-mono text-xs text-[var(--muted)]"
title={envKey}
>
{envKey}
</span>
<div className="flex-1 min-w-0 flex items-center gap-1.5">
<input
type={s?.visible ? "text" : "password"}
value={s?.loaded ? s.value : ""}
placeholder={s?.loaded ? "(empty)" : "loading..."}
onChange={(e) =>
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)]"
/>
<button
onClick={() =>
setStates((prev) => ({
...prev,
[envKey]: { ...prev[envKey], visible: !prev[envKey]?.visible },
}))
}
className="p-1 shrink-0 text-[var(--muted)] hover:text-[var(--foreground)]"
>
{s?.visible ? <EyeOff size={12} /> : <Eye size={12} />}
</button>
<button
onClick={() => handleCopy(envKey)}
disabled={!s?.loaded}
title="Copy secret value"
className="p-1 shrink-0 text-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-30"
>
{s?.copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</button>
<button
onClick={() => handleSave(envKey)}
disabled={!dirty || s?.saving}
className="p-1 shrink-0 text-blue-400 hover:text-blue-300 disabled:opacity-30"
>
{s?.saving ? (
<Loader2 size={12} className="animate-spin" />
) : s?.saved ? (
<Check size={12} className="text-green-400" />
) : (
<Save size={12} />
)}
</button>
<button
onClick={() => handleRemove(envKey)}
className="p-1 shrink-0 text-red-400 hover:text-red-300"
>
<Trash2 size={12} />
</button>
</div>
<span className="shrink-0 text-xs text-[var(--muted)]"></span>
<input
value={secretName}
onChange={(e) => 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"
/>
<span
className={cn(
"shrink-0 rounded px-1.5 py-0.5 text-[9px]",
isSet
? "bg-green-900/40 text-green-300"
: "bg-amber-900/40 text-amber-300",
)}
title={isSet ? "value set in the backend" : "no value set — won't resolve"}
>
{isSet ? "set" : "unset"}
</span>
<Link
to="/secrets"
title="Manage value on the Secrets page"
className="p-1 shrink-0 text-[var(--muted)] hover:text-[var(--foreground)]"
>
<ExternalLink size={12} />
</Link>
<button
onClick={() => removeRef(envKey)}
title="Remove this reference"
className="p-1 shrink-0 text-red-400 hover:text-red-300"
>
<Trash2 size={12} />
</button>
</div>
)
})}
<button onClick={handleAdd} className="text-xs text-[var(--primary)] hover:underline">
<Plus size={10} className="inline mr-1" />Add secret
</button>
<datalist id="all-secret-names">
{(names ?? []).map((n) => (
<option key={n} value={n} />
))}
</datalist>
{adding ? (
<div className="flex items-center gap-2">
<input
autoFocus
placeholder="ENV_VAR"
value={newEnv}
onChange={(e) => 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"
/>
<span className="shrink-0 text-xs text-[var(--muted)]"></span>
<input
placeholder="SECRET_NAME"
list="all-secret-names"
value={newSecret}
onChange={(e) => 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"
/>
<button onClick={add} className="text-xs text-[var(--primary)] hover:underline">
Add
</button>
<button onClick={() => setAdding(false)} className="text-xs text-[var(--muted)]">
Cancel
</button>
</div>
) : (
<button
onClick={() => setAdding(true)}
className="text-xs text-[var(--primary)] hover:underline"
>
<Plus size={10} className="inline mr-1" />
Add secret ref
</button>
)}
{anyUnset && (
<p className="text-[10px] text-amber-400/80">
Unset refs won't resolve set their value on the{" "}
<Link to="/secrets" className="underline">
Secrets
</Link>{" "}
page.
</p>
)}
</div>
)
}