feat: Updated secret group yaml view
Server Deploy / deploy (push) Successful in 1m38s

This commit is contained in:
domrichardson
2026-07-03 11:33:20 +01:00
parent 19596ff2a3
commit 7c30d26878
2 changed files with 281 additions and 198 deletions
+280 -197
View File
@@ -9,220 +9,303 @@ import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
// Name of the ClusterSecretStore the generated manifests reference.
const STORE_NAME = "vantage-store";
function CopyBlock({ label, yaml }: { label: string; yaml: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(yaml);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-medium text-text-secondary">{label}</span>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="overflow-x-auto rounded-lg border border-border bg-surface-2 p-3 font-mono text-xs leading-relaxed text-text-primary">{yaml}</pre>
</div>
);
}
function YamlModal({ group, onClose }: { group: string; onClose: () => void }) {
const [namespace, setNamespace] = useState(group);
const readUrl = typeof window !== "undefined" ? window.location.origin : "https://vantage.example.com";
const ns = namespace.trim() || group;
const externalSecret = `apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: ${group}
namespace: ${ns}
spec:
refreshInterval: 15m
secretStoreRef:
name: ${STORE_NAME}
kind: ClusterSecretStore
target:
name: ${group}
creationPolicy: Owner
dataFrom:
- extract:
key: ${group}`;
const clusterStore = `apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: ${STORE_NAME}
spec:
provider:
webhook:
url: "${readUrl}/secrets/{{ .remoteRef.key }}"
method: GET
result:
jsonPath: "$"
headers:
Content-Type: "application/json"
Authorization: "Bearer {{ .auth.token }}"
secrets:
- name: auth
secretRef:
name: vantage-eso-token
namespace: external-secrets`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-border bg-surface p-6">
<h2 className="mb-1 text-lg font-semibold text-text-primary">
Kubernetes manifests for <span className="font-mono">{group}</span>
</h2>
<p className="mb-5 text-sm text-text-secondary">Apply the ExternalSecret in your app&apos;s namespace to sync this group into a Kubernetes Secret via ESO.</p>
<div className="mb-5">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Namespace</label>
<input type="text" value={namespace} onChange={(e) => setNamespace(e.target.value)} placeholder={group} className={`${inputClass} font-mono`} />
</div>
<div className="space-y-5">
<CopyBlock label="ExternalSecret (apply per namespace)" yaml={externalSecret} />
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-text-secondary hover:text-text-primary">One-time cluster setup: ClusterSecretStore</summary>
<p className="mb-3 mt-2 text-xs text-text-tertiary">
Apply this once per cluster. It requires a Secret named <span className="font-mono">vantage-eso-token</span> in the <span className="font-mono">external-secrets</span>{" "}
namespace holding the read token from Settings, labelled <span className="font-mono">external-secrets.io/type=webhook</span>.
</p>
<CopyBlock label="ClusterSecretStore" yaml={clusterStore} />
</details>
</div>
<div className="mt-6 flex justify-end">
<Button variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
);
}
function SecretRow({ group, secret }: { group: string; secret: Secret }) {
const queryClient = useQueryClient();
const [revealed, setRevealed] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const queryClient = useQueryClient();
const [revealed, setRevealed] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { mutate: reveal, isPending: revealing } = useMutation({
mutationFn: () => api.revealSecret(group, secret.key),
onSuccess: (res) => setRevealed(res.value),
});
const { mutate: reveal, isPending: revealing } = useMutation({
mutationFn: () => api.revealSecret(group, secret.key),
onSuccess: (res) => setRevealed(res.value),
});
const { mutate: remove, isPending: removing } = useMutation({
mutationFn: () => api.deleteSecret(group, secret.key),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["secret-group", group] }),
});
const { mutate: remove, isPending: removing } = useMutation({
mutationFn: () => api.deleteSecret(group, secret.key),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["secret-group", group] }),
});
async function copy() {
if (revealed == null) return;
await navigator.clipboard.writeText(revealed);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
async function copy() {
if (revealed == null) return;
await navigator.clipboard.writeText(revealed);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Tr>
<Td>
<span className="font-mono font-medium text-text-primary">{secret.key}</span>
</Td>
<Td>
{revealed == null ? (
<span className="font-mono text-text-tertiary"></span>
) : (
<span className="font-mono text-xs break-all text-text-primary">{revealed}</span>
)}
</Td>
<Td>
<span className="text-text-secondary text-xs">
{new Date(secret.updated_at).toLocaleString()}
</span>
</Td>
<Td>
<div className="flex justify-end gap-2">
{revealed == null ? (
<Button variant="ghost" size="sm" loading={revealing} onClick={() => reveal()}>
Reveal
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
<Button variant="ghost" size="sm" onClick={() => setRevealed(null)}>
Hide
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
loading={removing}
className="text-danger hover:text-danger"
onClick={() => {
if (confirm(`Delete key "${secret.key}"?`)) remove();
}}
>
Delete
</Button>
</div>
</Td>
</Tr>
);
return (
<Tr>
<Td>
<span className="font-mono font-medium text-text-primary">{secret.key}</span>
</Td>
<Td>{revealed == null ? <span className="font-mono text-text-tertiary"></span> : <span className="font-mono text-xs break-all text-text-primary">{revealed}</span>}</Td>
<Td>
<span className="text-text-secondary text-xs">{new Date(secret.updated_at).toLocaleString()}</span>
</Td>
<Td>
<div className="flex justify-end gap-2">
{revealed == null ? (
<Button variant="ghost" size="sm" loading={revealing} onClick={() => reveal()}>
Reveal
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
<Button variant="ghost" size="sm" onClick={() => setRevealed(null)}>
Hide
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
loading={removing}
className="text-danger hover:text-danger"
onClick={() => {
if (confirm(`Delete key "${secret.key}"?`)) remove();
}}
>
Delete
</Button>
</div>
</Td>
</Tr>
);
}
function AddKeyCard({ group }: { group: string }) {
const queryClient = useQueryClient();
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const queryClient = useQueryClient();
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const { mutate: add, isPending, error } = useMutation({
mutationFn: () => api.putSecrets(group, { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
setKey("");
setValue("");
},
});
const {
mutate: add,
isPending,
error,
} = useMutation({
mutationFn: () => api.putSecrets(group, { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
setKey("");
setValue("");
},
});
return (
<Card>
<CardHeader>
<CardTitle>Add / Update Key</CardTitle>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">
Adding a key that already exists overwrites its value. Others are left untouched.
</p>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API_KEY"
className={`${inputClass} font-mono`}
/>
</div>
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="myapikey456"
className={`${inputClass} font-mono`}
/>
</div>
<Button
variant="primary"
loading={isPending}
disabled={!key.trim() || !value}
onClick={() => add()}
>
Save
</Button>
</div>
</Card>
);
return (
<Card>
<CardHeader>
<CardTitle>Add / Update Key</CardTitle>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">Adding a key that already exists overwrites its value. Others are left untouched.</p>
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key</label>
<input type="text" value={key} onChange={(e) => setKey(e.target.value)} placeholder="API_KEY" className={`${inputClass} font-mono`} />
</div>
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input type="text" value={value} onChange={(e) => setValue(e.target.value)} placeholder="myapikey456" className={`${inputClass} font-mono`} />
</div>
<Button variant="primary" loading={isPending} disabled={!key.trim() || !value} onClick={() => add()}>
Save
</Button>
</div>
</Card>
);
}
export default function SecretGroupPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const group = decodeURIComponent(String(params.group));
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const group = decodeURIComponent(String(params.group));
const [showYaml, setShowYaml] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["secret-group", group],
queryFn: () => api.getSecretGroup(group),
});
const { data, isLoading, error } = useQuery({
queryKey: ["secret-group", group],
queryFn: () => api.getSecretGroup(group),
});
const { mutate: deleteGroup, isPending: deleting } = useMutation({
mutationFn: () => api.deleteSecretGroup(group),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
router.push("/secrets");
},
});
const { mutate: deleteGroup, isPending: deleting } = useMutation({
mutationFn: () => api.deleteSecretGroup(group),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
router.push("/secrets");
},
});
return (
<div className="p-8">
<Link href="/secrets" className="mb-4 inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary">
Back to secrets
</Link>
return (
<div className="p-8">
{showYaml && <YamlModal group={group} onClose={() => setShowYaml(false)} />}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-mono text-2xl font-bold text-text-primary">{group}</h1>
<p className="mt-1 text-sm text-text-secondary">
ESO reads this group at <span className="font-mono">GET /secrets/{group}</span>
</p>
<Link href="/secrets" className="mb-4 inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary">
Back to secrets
</Link>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-mono text-2xl font-bold text-text-primary">{group}</h1>
<p className="mt-1 text-sm text-text-secondary">
ESO reads this group at <span className="font-mono">GET /secrets/{group}</span>
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="ghost" onClick={() => setShowYaml(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
ExternalSecret YAML
</Button>
<Button
variant="ghost"
className="text-danger hover:text-danger"
loading={deleting}
onClick={() => {
if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
}}
>
Delete Group
</Button>
</div>
</div>
<div className="space-y-6">
<AddKeyCard group={group} />
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">Failed to load group. It may have been deleted.</div>
) : data && data.secrets.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Key</Th>
<Th>Value</Th>
<Th>Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{data.secrets.map((s: Secret) => (
<SecretRow key={s.key} group={group} secret={s} />
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">This group has no keys. Add one above.</div>
)}
</Card>
</div>
</div>
<Button
variant="ghost"
className="text-danger hover:text-danger"
loading={deleting}
onClick={() => {
if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
}}
>
Delete Group
</Button>
</div>
<div className="space-y-6">
<AddKeyCard group={group} />
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">
Failed to load group. It may have been deleted.
</div>
) : data && data.secrets.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Key</Th>
<Th>Value</Th>
<Th>Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{data.secrets.map((s: Secret) => (
<SecretRow key={s.key} group={group} secret={s} />
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">
This group has no keys. Add one above.
</div>
)}
</Card>
</div>
</div>
);
);
}