This commit is contained in:
+280
-197
@@ -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'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>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user