feat(FN-4911): complete Step 3 — add secrets view component

Fusion-Task-Id: FN-4911
Fusion-Task-Lineage: 021e3ee9-a776-4648-a17d-8ae1a7ebc5be
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 10:57:41 -07:00
committed by gsxdsm
parent 73c6f46b5b
commit ff7c2336cf
2 changed files with 372 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
.secrets-view {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.secrets-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.secrets-header-actions {
display: flex;
gap: var(--space-sm);
}
.secrets-loading,
.secrets-empty {
color: var(--text-muted);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.secrets-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.secrets-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-md);
}
.secrets-row-main {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.secrets-row-key {
font-family: var(--font-mono);
}
.secrets-row-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.secrets-chip {
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-pill);
padding: var(--space-xs) var(--space-sm);
color: var(--text-muted);
}
.secrets-revealed {
margin: 0;
white-space: pre-wrap;
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
}
.secrets-row-side {
display: flex;
flex-direction: column;
gap: var(--space-sm);
align-items: flex-end;
}
.secrets-row-read {
color: var(--text-muted);
}
.secrets-row-actions,
.secrets-confirm,
.secrets-value-row,
.secrets-radio-row {
display: flex;
gap: var(--space-sm);
align-items: center;
}
.secrets-value-row .input {
flex: 1;
}
@media (max-width: 768px) {
.secrets-header,
.secrets-row {
grid-template-columns: minmax(0, 1fr);
display: flex;
flex-direction: column;
align-items: stretch;
}
.secrets-row-side {
align-items: flex-start;
}
}

View File

@@ -0,0 +1,266 @@
import "./SecretsView.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, Copy, Eye, EyeOff, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
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;
}
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: "",
};
export const SecretsView = ({ addToast }: SecretsViewProps) => {
const [secrets, setSecrets] = useState<SecretRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [editing, setEditing] = useState<SecretRecord | null>(null);
const [showModal, setShowModal] = useState(false);
const [showDeleteId, setShowDeleteId] = useState<string | null>(null);
const [form, setForm] = useState<SecretFormState>(EMPTY_FORM);
const [showValue, setShowValue] = useState(false);
const [revealedValues, setRevealedValues] = useState<Record<string, string | null>>({});
const [copiedId, setCopiedId] = useState<string | null>(null);
const revealTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const copyTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const request = useCallback(async <T,>(url: string, init?: RequestInit): Promise<T> => {
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<T>;
}, []);
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]);
useEffect(() => {
void loadSecrets();
return () => {
revealTimersRef.current.forEach((timer) => clearTimeout(timer));
copyTimersRef.current.forEach((timer) => clearTimeout(timer));
};
}, [loadSecrets]);
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<string, unknown> = {
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 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?.("Revealed", "success");
const timer = setTimeout(() => {
setRevealedValues((current) => ({ ...current, [secret.id]: null }));
}, 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?.("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].sort((a, b) => a.key.localeCompare(b.key)), [secrets]);
return (
<section className="secrets-view">
<div className="secrets-header">
<h2>Secrets</h2>
<div className="secrets-header-actions">
<button className="btn btn-sm" onClick={() => void loadSecrets()}><RefreshCw size={14} /> Refresh</button>
<button className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> Add Secret</button>
</div>
</div>
{error ? <div className="form-error">{error}</div> : null}
{loading ? <div className="secrets-loading"><RefreshCw size={14} className="spin" /> Loading…</div> : null}
{!loading && sortedSecrets.length === 0 ? <div className="secrets-empty">No secrets found.</div> : null}
<div className="secrets-list">
{sortedSecrets.map((secret) => {
const revealed = revealedValues[secret.id];
return (
<article key={secret.id} className="card secrets-row">
<div className="secrets-row-main">
<div className="secrets-row-key">{secret.key}</div>
<div className="secrets-row-meta">
<span className="secrets-chip">{secret.scope}</span>
<span className="secrets-chip">{secret.accessPolicy}</span>
{secret.envExportable ? <span className="secrets-chip">env_exportable</span> : null}
</div>
{revealed ? <pre className="secrets-revealed">{revealed}</pre> : null}
</div>
<div className="secrets-row-side">
<span className="secrets-row-read">{secret.lastReadAt ? new Date(secret.lastReadAt).toLocaleString() : "Never read"}</span>
<div className="secrets-row-actions">
<button className="btn btn-icon" onClick={() => void revealSecret(secret)} aria-label="Reveal">
{revealed ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button className="btn btn-icon" onClick={() => void copySecret(secret)} aria-label="Copy" disabled={!revealed}>
{copiedId === secret.id ? <Check size={14} /> : <Copy size={14} />}
</button>
<button className="btn btn-icon" onClick={() => openEdit(secret)} aria-label="Edit"><Pencil size={14} /></button>
<button className="btn btn-icon btn-danger" onClick={() => setShowDeleteId(secret.id)} aria-label="Delete"><Trash2 size={14} /></button>
</div>
{showDeleteId === secret.id ? (
<div className="secrets-confirm">
<button className="btn btn-sm btn-danger" onClick={() => void deleteSecret(secret)}>Confirm</button>
<button className="btn btn-sm" onClick={() => setShowDeleteId(null)}>Cancel</button>
</div>
) : null}
</div>
</article>
);
})}
</div>
{showModal ? (
<div className="modal-overlay open" role="presentation">
<div className="modal" role="dialog" aria-modal="true" aria-label={editing ? "Edit secret" : "Add secret"}>
<div className="modal-header">
<h3>{editing ? "Edit secret" : "Add secret"}</h3>
<button className="modal-close" onClick={() => setShowModal(false)} aria-label="Close">×</button>
</div>
<div className="form-group"><label>Key</label><input className="input" value={form.key} onChange={(e) => setForm((c) => ({ ...c, key: e.target.value }))} /></div>
<div className="form-group"><label>Value</label><div className="secrets-value-row"><input className="input" type={showValue ? "text" : "password"} autoComplete="off" spellCheck={false} value={form.value} onChange={(e) => setForm((c) => ({ ...c, value: e.target.value }))} /><button className="btn btn-icon" onClick={() => setShowValue((s) => !s)}>{showValue ? <EyeOff size={14} /> : <Eye size={14} />}</button></div></div>
<div className="form-group"><label>Description</label><textarea className="input" value={form.description} onChange={(e) => setForm((c) => ({ ...c, description: e.target.value }))} /></div>
<div className="form-group"><label>Scope</label><div className="secrets-radio-row"><label><input type="radio" checked={form.scope === "project"} onChange={() => setForm((c) => ({ ...c, scope: "project" }))} disabled={Boolean(editing)} /> Project</label><label><input type="radio" checked={form.scope === "global"} onChange={() => setForm((c) => ({ ...c, scope: "global" }))} disabled={Boolean(editing)} /> Global</label></div></div>
<div className="form-group"><label>Access policy</label><select className="select" value={form.accessPolicy} onChange={(e) => setForm((c) => ({ ...c, accessPolicy: e.target.value as SecretPolicy }))}><option value="auto">auto</option><option value="prompt">prompt</option><option value="deny">deny</option></select></div>
<div className="form-group"><label className="checkbox-label"><input type="checkbox" checked={form.envExportable} onChange={(e) => setForm((c) => ({ ...c, envExportable: e.target.checked }))} /> Export to env</label></div>
{form.envExportable ? <div className="form-group"><label>Env key</label><input className="input" value={form.envExportKey} onChange={(e) => setForm((c) => ({ ...c, envExportKey: e.target.value }))} /></div> : null}
{formError ? <div className="form-error">{formError}</div> : null}
<div className="modal-actions"><div className="modal-actions-right"><button className="btn" onClick={() => setShowModal(false)}>Cancel</button><button className="btn btn-primary" onClick={() => void submit()}>{editing ? "Save" : "Create"}</button></div></div>
</div>
</div>
) : null}
</section>
);
};