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:
committed by
gsxdsm
parent
73c6f46b5b
commit
ff7c2336cf
106
packages/dashboard/app/components/SecretsView.css
Normal file
106
packages/dashboard/app/components/SecretsView.css
Normal 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;
|
||||
}
|
||||
}
|
||||
266
packages/dashboard/app/components/SecretsView.tsx
Normal file
266
packages/dashboard/app/components/SecretsView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user