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:
@@ -1,210 +1,142 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useState } from "react"
|
||||||
import { Check, Copy, Eye, EyeOff, Loader2, Plus, Save, Trash2 } from "lucide-react"
|
import { Link } from "react-router-dom"
|
||||||
import { apiClient } from "@/services/api/client"
|
import { ExternalLink, Plus, Trash2 } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useSecrets } from "@/services/api/hooks"
|
||||||
|
|
||||||
interface SecretsEditorProps {
|
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>
|
secrets: Record<string, string>
|
||||||
onSecretsChange: (secrets: Record<string, string>) => void
|
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) {
|
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
|
const setRef = (envKey: string, secretName: string) =>
|
||||||
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
|
|
||||||
onSecretsChange({ ...secrets, [envKey]: secretName })
|
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 removeRef = (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 next = { ...secrets }
|
const next = { ...secrets }
|
||||||
delete next[envKey]
|
delete next[envKey]
|
||||||
onSecretsChange(next)
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Object.entries(secrets).map(([envKey, secretName]) => {
|
{Object.entries(secrets).map(([envKey, secretName]) => {
|
||||||
const s = states[envKey]
|
const isSet = known.has(secretName)
|
||||||
const dirty = s ? s.value !== s.original : false
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={envKey} className="flex items-center gap-2">
|
<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}
|
{envKey}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
<span className="shrink-0 text-xs text-[var(--muted)]">→</span>
|
||||||
<input
|
<input
|
||||||
type={s?.visible ? "text" : "password"}
|
value={secretName}
|
||||||
value={s?.loaded ? s.value : ""}
|
onChange={(e) => setRef(envKey, e.target.value)}
|
||||||
placeholder={s?.loaded ? "(empty)" : "loading..."}
|
list="all-secret-names"
|
||||||
onChange={(e) =>
|
placeholder="SECRET_NAME"
|
||||||
setStates((prev) => ({
|
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"
|
||||||
...prev,
|
/>
|
||||||
[envKey]: { ...prev[envKey], value: e.target.value },
|
<span
|
||||||
}))
|
className={cn(
|
||||||
}
|
"shrink-0 rounded px-1.5 py-0.5 text-[9px]",
|
||||||
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)]"
|
isSet
|
||||||
/>
|
? "bg-green-900/40 text-green-300"
|
||||||
<button
|
: "bg-amber-900/40 text-amber-300",
|
||||||
onClick={() =>
|
)}
|
||||||
setStates((prev) => ({
|
title={isSet ? "value set in the backend" : "no value set — won't resolve"}
|
||||||
...prev,
|
>
|
||||||
[envKey]: { ...prev[envKey], visible: !prev[envKey]?.visible },
|
{isSet ? "set" : "unset"}
|
||||||
}))
|
</span>
|
||||||
}
|
<Link
|
||||||
className="p-1 shrink-0 text-[var(--muted)] hover:text-[var(--foreground)]"
|
to="/secrets"
|
||||||
>
|
title="Manage value on the Secrets page"
|
||||||
{s?.visible ? <EyeOff size={12} /> : <Eye size={12} />}
|
className="p-1 shrink-0 text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||||
</button>
|
>
|
||||||
<button
|
<ExternalLink size={12} />
|
||||||
onClick={() => handleCopy(envKey)}
|
</Link>
|
||||||
disabled={!s?.loaded}
|
<button
|
||||||
title="Copy secret value"
|
onClick={() => removeRef(envKey)}
|
||||||
className="p-1 shrink-0 text-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-30"
|
title="Remove this reference"
|
||||||
>
|
className="p-1 shrink-0 text-red-400 hover:text-red-300"
|
||||||
{s?.copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
|
>
|
||||||
</button>
|
<Trash2 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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<button onClick={handleAdd} className="text-xs text-[var(--primary)] hover:underline">
|
|
||||||
<Plus size={10} className="inline mr-1" />Add secret
|
<datalist id="all-secret-names">
|
||||||
</button>
|
{(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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user