import "./SecretsView.css"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronDown, ChevronRight, Copy, Eye, EyeOff, Lock, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { ViewHeader } from "./ViewHeader"; type ToastKind = "info" | "success" | "error"; type SecretScope = "project" | "global"; type SecretPolicy = "auto" | "prompt" | "deny"; interface SecretRecord { id: string; scope: SecretScope; key: string; description: string | null; accessPolicy: SecretPolicy; envExportable: boolean; envExportKey: string | null; lastReadAt: string | null; } interface SecretsViewProps { addToast?: (msg: string, kind?: ToastKind) => void; } const RESERVED_SYNC_PASSPHRASE_KEY = "__sync_passphrase__"; interface SecretFormState { key: string; value: string; description: string; scope: SecretScope; accessPolicy: SecretPolicy; envExportable: boolean; envExportKey: string; } const EMPTY_FORM: SecretFormState = { key: "", value: "", description: "", scope: "project", accessPolicy: "prompt", envExportable: false, envExportKey: "", }; const actionIconProps = { className: "secrets-action-icon", "aria-hidden": true, style: { width: "1em", height: "1em" }, } as const; const spinningActionIconProps = { ...actionIconProps, className: "secrets-action-icon spin", } as const; export const SecretsView = ({ addToast }: SecretsViewProps) => { const { t } = useTranslation("app"); const [secrets, setSecrets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [formError, setFormError] = useState(null); const [editing, setEditing] = useState(null); const [showModal, setShowModal] = useState(false); const [showDeleteId, setShowDeleteId] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [showValue, setShowValue] = useState(false); const [revealedValues, setRevealedValues] = useState>({}); const [copiedId, setCopiedId] = useState(null); const [syncPassphraseConfigured, setSyncPassphraseConfigured] = useState(false); const [syncModalOpen, setSyncModalOpen] = useState(false); const [syncPassphrase, setSyncPassphrase] = useState(""); const [syncPassphraseConfirm, setSyncPassphraseConfirm] = useState(""); const [syncSaving, setSyncSaving] = useState(false); /* FNXC:Secrets 2026-06-23-01:30: The cross-node sync passphrase is an advanced, rarely-touched setting, so it now lives BELOW the secrets list and is collapsed behind a disclosure that is closed by default. Users click the toggle to expand the passphrase status/actions + description. All set/rotate/clear functionality is unchanged; only relocated and gated behind this toggle. */ const [syncDisclosureOpen, setSyncDisclosureOpen] = useState(false); const revealTimersRef = useRef>>(new Map()); const copyTimersRef = useRef>>(new Map()); const request = useCallback(async (url: string, init?: RequestInit): Promise => { const response = await fetch(url, { ...init, headers: { "Content-Type": "application/json", ...(init?.headers ?? {}), }, }); if (!response.ok) { const payload = await response.json().catch(() => ({ error: "Request failed" })); throw new Error(String(payload?.error ?? "Request failed")); } if (response.status === 204) return undefined as T; return response.json() as Promise; }, []); const loadSecrets = useCallback(async () => { setLoading(true); setError(null); try { const data = await request<{ secrets: SecretRecord[] }>("/api/secrets"); setSecrets(data.secrets); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }, [request]); const loadSyncPassphraseStatus = useCallback(async () => { try { const data = await request<{ configured: boolean }>("/api/secrets/sync-passphrase"); setSyncPassphraseConfigured(Boolean(data.configured)); } catch (err) { addToast?.(t("secrets.errorLoadSyncStatus", "Failed to load sync passphrase status: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error"); } }, [addToast, request]); useEffect(() => { void loadSecrets(); void loadSyncPassphraseStatus(); return () => { revealTimersRef.current.forEach((timer) => clearTimeout(timer)); copyTimersRef.current.forEach((timer) => clearTimeout(timer)); }; }, [loadSecrets, loadSyncPassphraseStatus]); const closeSyncModal = () => { setSyncModalOpen(false); setSyncPassphrase(""); setSyncPassphraseConfirm(""); }; const saveSyncPassphrase = async (passphrase: string) => { await request<{ success: boolean }>("/api/secrets/sync-passphrase", { method: "PUT", body: JSON.stringify({ passphrase }), }); }; const submitSyncPassphrase = async () => { setSyncSaving(true); try { await saveSyncPassphrase(syncPassphrase); addToast?.(syncPassphraseConfigured ? t("secrets.syncPassphraseRotated", "Sync passphrase rotated") : t("secrets.syncPassphraseSet", "Sync passphrase set"), "success"); closeSyncModal(); await loadSyncPassphraseStatus(); } catch (err) { addToast?.(t("secrets.errorSaveSyncPassphrase", "Failed to save sync passphrase: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error"); } finally { setSyncSaving(false); } }; const clearSyncPassphraseHandler = async () => { const confirmed = window.confirm(t("secrets.confirmClearSyncPassphrase", "Clear the cross-node sync passphrase? Existing sync pairs will stop working until you set a new passphrase.")); if (!confirmed) return; try { await request<{ success: boolean }>("/api/secrets/sync-passphrase", { method: "DELETE" }); addToast?.(t("secrets.syncPassphraseCleared", "Sync passphrase cleared"), "success"); await loadSyncPassphraseStatus(); } catch (err) { addToast?.(t("secrets.errorClearSyncPassphrase", "Failed to clear sync passphrase: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error"); } }; const openCreate = () => { setEditing(null); setForm(EMPTY_FORM); setShowModal(true); setShowValue(false); setFormError(null); }; const openEdit = (secret: SecretRecord) => { setEditing(secret); setForm({ key: secret.key, value: "", description: secret.description ?? "", scope: secret.scope, accessPolicy: secret.accessPolicy, envExportable: secret.envExportable, envExportKey: secret.envExportKey ?? "", }); setShowModal(true); setShowValue(false); setFormError(null); }; const submit = async () => { setFormError(null); try { if (editing) { const body: Record = { key: form.key, description: form.description || null, accessPolicy: form.accessPolicy, envExportable: form.envExportable, envExportKey: form.envExportable ? (form.envExportKey || null) : null, }; if (form.value) body.value = form.value; await request(`/api/secrets/${editing.scope}/${editing.id}`, { method: "PATCH", body: JSON.stringify(body), }); } else { await request("/api/secrets", { method: "POST", body: JSON.stringify({ scope: form.scope, key: form.key, value: form.value, description: form.description || null, accessPolicy: form.accessPolicy, envExportable: form.envExportable, envExportKey: form.envExportable ? (form.envExportKey || null) : null, }), }); } setShowModal(false); setForm(EMPTY_FORM); await loadSecrets(); } catch (err) { setFormError(err instanceof Error ? err.message : String(err)); } }; const hideSecret = (secret: SecretRecord) => { const existing = revealTimersRef.current.get(secret.id); if (existing) { clearTimeout(existing); revealTimersRef.current.delete(secret.id); } setRevealedValues((current) => ({ ...current, [secret.id]: null })); }; const revealSecret = async (secret: SecretRecord) => { const data = await request<{ key: string; value: string }>(`/api/secrets/${secret.scope}/${secret.id}/reveal`, { method: "POST" }); setRevealedValues((current) => ({ ...current, [secret.id]: data.value })); addToast?.(t("secrets.revealed", "Revealed"), "success"); const timer = setTimeout(() => { setRevealedValues((current) => ({ ...current, [secret.id]: null })); revealTimersRef.current.delete(secret.id); }, 30000); const existing = revealTimersRef.current.get(secret.id); if (existing) clearTimeout(existing); revealTimersRef.current.set(secret.id, timer); }; const copySecret = async (secret: SecretRecord) => { const revealed = revealedValues[secret.id]; if (!revealed) return; await navigator.clipboard.writeText(revealed); setCopiedId(secret.id); addToast?.(t("secrets.copied", "Copied"), "success"); const timer = setTimeout(() => { setCopiedId(null); setRevealedValues((current) => ({ ...current, [secret.id]: null })); }, 1500); const existing = copyTimersRef.current.get(secret.id); if (existing) clearTimeout(existing); copyTimersRef.current.set(secret.id, timer); }; const deleteSecret = async (secret: SecretRecord) => { await request(`/api/secrets/${secret.scope}/${secret.id}`, { method: "DELETE" }); setShowDeleteId(null); await loadSecrets(); }; const sortedSecrets = useMemo( () => [...secrets] .filter((secret) => !(secret.scope === "global" && secret.key === RESERVED_SYNC_PASSPHRASE_KEY)) .sort((a, b) => a.key.localeCompare(b.key)), [secrets], ); const syncPassphraseMatches = syncPassphrase.length > 0 && syncPassphrase === syncPassphraseConfirm; return (
{/* FNXC:ViewHeader 2026-06-23-03:45: Secrets now renders the shared canonical ViewHeader (Lock icon matches the right-dock nav). The Refresh/Add actions ride in the header actions cluster as btn btn-sm so they match every other view's header buttons. The right-dock/pop-out hosts still hide this title row via the `.secrets-view > .view-header` selector since those chromes label the view themselves. */} } /> {error ?
{error}
: null} {loading ?
{t("secrets.loading", "Loading…")}
: null} {!loading && sortedSecrets.length === 0 ?
{t("secrets.empty", "No secrets found.")}
: null}
{sortedSecrets.map((secret) => { const revealed = revealedValues[secret.id]; return (
{secret.key}
{secret.scope} {secret.accessPolicy} {secret.envExportable ? {t("secrets.envExportableChip", "env exportable")} : null}
{revealed ?
{revealed}
: null}
{secret.lastReadAt ? new Date(secret.lastReadAt).toLocaleString() : t("secrets.neverRead", "Never read")}
{showDeleteId === secret.id ? (
) : null}
); })}
{/* FNXC:Secrets 2026-06-23-01:30: Disclosure (closed by default) sits below the secrets list. The toggle button carries aria-expanded/aria-controls and a rotating chevron; the passphrase status, set/rotate/clear actions, and description only render when expanded. */}
{syncDisclosureOpen ? (

{syncPassphraseConfigured ? : null}

{t("secrets.syncPassphraseDescription", "Shared passphrase used to wrap cross-node secret bundles. Both nodes in a sync pair must share the same value. Stored locally only; never transmitted.")}

) : null}
{syncModalOpen ? (

{syncPassphraseConfigured ? t("secrets.rotateSyncPassphraseModalTitle", "Rotate sync passphrase") : t("secrets.setSyncPassphraseModalTitle", "Set sync passphrase")}

setSyncPassphrase(e.target.value)} />
setSyncPassphraseConfirm(e.target.value)} />
{!syncPassphraseMatches && syncPassphraseConfirm.length > 0 ?
{t("secrets.passphraseMustMatch", "Passphrases must match.")}
: null}
) : null} {showModal ? (

{editing ? t("secrets.editSecretModalTitle", "Edit secret") : t("secrets.addSecretModalTitle", "Add secret")}

setForm((c) => ({ ...c, key: e.target.value }))} />
setForm((c) => ({ ...c, value: e.target.value }))} />