This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user