feat: Secret management
Server Deploy / deploy (push) Successful in 1m33s

This commit is contained in:
domrichardson
2026-07-03 10:37:43 +01:00
parent c3c16083f7
commit 19596ff2a3
15 changed files with 1757 additions and 8 deletions
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Secret } from "@/lib/api";
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";
function SecretRow({ group, secret }: { group: string; secret: Secret }) {
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: 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);
}
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 { 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>
);
}
export default function SecretGroupPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const group = decodeURIComponent(String(params.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");
},
});
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>
<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>
<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>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, SecretGroupSummary } from "@/lib/api";
import { Button, Card } 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";
function NewGroupModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const [group, setGroup] = useState("");
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const { mutate: create, isPending, error } = useMutation({
mutationFn: () =>
api.createSecretGroup(group.trim(), { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
onClose();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-border bg-surface p-6">
<h2 className="mb-1 text-lg font-semibold text-text-primary">New Secret Group</h2>
<p className="mb-4 text-sm text-text-secondary">
A group must be created with at least one key. You can add more keys afterwards.
</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="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Group name</label>
<input
type="text"
value={group}
onChange={(e) => setGroup(e.target.value)}
placeholder="e.g. myapp-prod"
className={`${inputClass} font-mono`}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">First key</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="DB_PASSWORD"
className={`${inputClass} font-mono`}
/>
</div>
<div>
<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="supersecret123"
className={`${inputClass} font-mono`}
/>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
variant="primary"
loading={isPending}
disabled={!group.trim() || !key.trim() || !value}
onClick={() => create()}
>
Create Group
</Button>
</div>
</div>
</div>
);
}
export default function SecretsPage() {
const [showNew, setShowNew] = useState(false);
const { data: groups, isLoading, error } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
});
return (
<div className="p-8">
{showNew && <NewGroupModal onClose={() => setShowNew(false)} />}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Secrets</h1>
<p className="mt-1 text-sm text-text-secondary">
{groups?.length ?? 0} group{groups?.length !== 1 ? "s" : ""} · encrypted at rest, exposed to Kubernetes via ESO
</p>
</div>
<Button variant="primary" onClick={() => setShowNew(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New Group
</Button>
</div>
<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 secrets. Is the backend running?
</div>
) : groups && groups.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Group</Th>
<Th>Keys</Th>
<Th>Last Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{groups.map((g: SecretGroupSummary) => (
<Tr key={g.group}>
<Td>
<span className="font-mono font-medium text-text-primary">{g.group}</span>
</Td>
<Td>
<span className="text-text-secondary">
{g.key_count} key{g.key_count !== 1 ? "s" : ""}
</span>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{new Date(g.updated_at).toLocaleString()}
</span>
</Td>
<Td>
<Link href={`/secrets/${encodeURIComponent(g.group)}`}>
<Button variant="ghost" size="sm">View </Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<p className="text-text-secondary">No secret groups yet.</p>
<Button variant="primary" size="sm" className="mt-4" onClick={() => setShowNew(true)}>
Create your first group
</Button>
</div>
)}
</Card>
</div>
);
}
+86
View File
@@ -66,6 +66,85 @@ function Field({
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
function SecretsTokenCard({
tokenSet,
rotatedAt,
}: {
tokenSet: boolean;
rotatedAt?: string;
}) {
const queryClient = useQueryClient();
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const readUrl =
typeof window !== "undefined"
? `${window.location.origin}/secrets/<group>`
: "/secrets/<group>";
const { mutate: rotate, isPending } = useMutation({
mutationFn: api.rotateSecretsToken,
onSuccess: (res) => {
setToken(res.token);
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
async function copy() {
if (!token) return;
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card>
<CardHeader>
<CardTitle>Secrets Read Token (ESO)</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer
token. Point your <span className="font-mono">ClusterSecretStore</span> at{" "}
<span className="font-mono text-text-primary">{readUrl}</span>.
</p>
<div className="mb-4 flex items-center gap-2 text-sm">
<span
className={`inline-block h-2 w-2 rounded-full ${tokenSet ? "bg-success" : "bg-text-tertiary"}`}
/>
<span className="text-text-secondary">
{tokenSet ? "A read token is configured" : "No read token configured yet"}
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
</span>
</div>
{token && (
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 p-3">
<p className="mb-2 text-xs font-medium text-warning">
Copy this token now it will not be shown again.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">
{token}
</code>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
)}
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
{tokenSet ? "Rotate Token" : "Generate Token"}
</Button>
{tokenSet && (
<p className="mt-2 text-xs text-text-tertiary">
Rotating invalidates the previous token. Update the Kubernetes secret afterwards.
</p>
)}
</Card>
);
}
export default function SettingsPage() {
const queryClient = useQueryClient();
@@ -302,6 +381,13 @@ export default function SettingsPage() {
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
</div>
</form>
<div className="mt-6 max-w-xl">
<SecretsTokenCard
tokenSet={settings?.secrets?.read_token_set ?? false}
rotatedAt={settings?.secrets?.rotated_at}
/>
</div>
</div>
);
}
+9
View File
@@ -27,6 +27,14 @@ function KeyIcon() {
);
}
function SecretIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
);
}
function AuditIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -47,6 +55,7 @@ function SettingsIcon() {
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings", label: "Settings", icon: <SettingsIcon /> },
];
+62
View File
@@ -69,9 +69,27 @@ export interface EmailSettings {
use_tls: boolean;
}
export interface SecretsSettings {
read_token_set: boolean;
rotated_at?: string;
}
export interface Settings {
alerts: AlertSettings;
email: EmailSettings;
secrets: SecretsSettings;
}
export interface SecretGroupSummary {
group: string;
key_count: number;
updated_at: string;
}
export interface Secret {
group: string;
key: string;
updated_at: string;
}
export interface NewServerResponse {
@@ -191,6 +209,50 @@ export const api = {
});
},
rotateSecretsToken(): Promise<{ token: string }> {
return request<{ token: string }>("/settings/secrets-token", { method: "POST" });
},
// Secrets
listSecretGroups(): Promise<SecretGroupSummary[]> {
return request<SecretGroupSummary[]>("/secrets");
},
createSecretGroup(group: string, values: Record<string, string>): Promise<{ group: string }> {
return request<{ group: string }>("/secrets", {
method: "POST",
body: JSON.stringify({ group, values }),
});
},
getSecretGroup(group: string): Promise<{ group: string; secrets: Secret[] }> {
return request<{ group: string; secrets: Secret[] }>(`/secrets/${encodeURIComponent(group)}`);
},
putSecrets(group: string, values: Record<string, string>): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>(`/secrets/${encodeURIComponent(group)}`, {
method: "PUT",
body: JSON.stringify(values),
});
},
revealSecret(group: string, key: string): Promise<{ value: string }> {
return request<{ value: string }>(`/secrets/${encodeURIComponent(group)}/reveal`, {
method: "POST",
body: JSON.stringify({ key }),
});
},
deleteSecret(group: string, key: string): Promise<void> {
return request<void>(`/secrets/${encodeURIComponent(group)}/${encodeURIComponent(key)}`, {
method: "DELETE",
});
},
deleteSecretGroup(group: string): Promise<void> {
return request<void>(`/secrets/${encodeURIComponent(group)}`, { method: "DELETE" });
},
// Keys
listKeys(): Promise<Key[]> {
return request<Key[]>("/keys");
File diff suppressed because one or more lines are too long