import { useMemo, useState } from "react" import { sshApi } from "@/api/ssh.ts" import type { DtoCreateSSHRequest, DtoSshRevealResponse } from "@/sdk" import { zodResolver } from "@hookform/resolvers/zod" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { Download, Eye, Loader2, Plus, Trash2 } from "lucide-react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { z } from "zod" import { truncateMiddle } from "@/lib/utils.ts" import { Badge } from "@/components/ui/badge.tsx" import { Button } from "@/components/ui/button.tsx" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog.tsx" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form.tsx" import { Input } from "@/components/ui/input.tsx" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select.tsx" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table.tsx" import { Textarea } from "@/components/ui/textarea.tsx" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip.tsx" const createKeySchema = z.object({ name: z.string().trim().min(1, "Name is required").max(100, "Max 100 characters"), comment: z.string().trim().min(1, "Comment is required").max(100, "Max 100 characters"), bits: z.enum(["2048", "3072", "4096"]).optional(), type: z.enum(["rsa", "ed25519"]).optional(), }) type CreateKeyInput = z.input function saveBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = filename document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) } function copy(text: string, label = "Copied") { navigator.clipboard .writeText(text) .then(() => toast.success(label)) .catch(() => toast.error("Copy failed")) } function getKeyType(publicKey: string) { return publicKey?.split(/\s+/)?.[0] ?? "ssh-key" } export const SshPage = () => { const [filter, setFilter] = useState("") const [createOpen, setCreateOpen] = useState(false) const [revealFor, setRevealFor] = useState(null) const [deleteId, setDeleteId] = useState(null) const qc = useQueryClient() const sshQ = useQuery({ queryKey: ["ssh"], queryFn: () => sshApi.listSshKeys(), }) const form = useForm({ resolver: zodResolver(createKeySchema), defaultValues: { name: "", comment: "", type: "rsa", bits: "4096", }, }) const createMutation = useMutation({ mutationFn: async (values: CreateKeyInput) => { const payload: DtoCreateSSHRequest = { name: values.name, comment: values.comment, // Only send bits for RSA bits: values.type === "rsa" && values.bits ? Number(values.bits) : undefined, // Only send type if present type: values.type, } return await sshApi.createSshKey(payload) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ["ssh"] }) setCreateOpen(false) form.reset({ name: "", comment: "", type: "rsa", bits: "4096" }) toast.success("SSH Key created") }, onError: (e: any) => toast.error(e?.message ?? "SSH Key creation failed"), }) const revealMutation = useMutation({ mutationFn: (id: string) => sshApi.revealSshKeyById(id), onSuccess: (data) => setRevealFor(data), onError: (e: any) => toast.error(e?.message ?? "Failed to reveal key"), }) const deleteMutation = useMutation({ mutationFn: (id: string) => sshApi.deleteSshKey(id), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["ssh"] }) setDeleteId(null) toast.success("SSH Key deleted") }, onError: (e: any) => toast.error(e?.message ?? "Delete failed"), }) const filtered = useMemo(() => { const q = filter.trim().toLowerCase() if (!q) return sshQ.data ?? [] return (sshQ.data ?? []).filter((k) => { return ( k.name?.toLowerCase().includes(q) || k.fingerprint?.toLowerCase().includes(q) || k.public_key?.toLowerCase().includes(q) ) }) }, [filter, sshQ.data]) if (sshQ.isLoading) return
Loading SSH Keys…
if (sshQ.error) return
Error Loading SSH Keys
return (

SSH Keys

setFilter(e.target.value)} placeholder="Search by name, fingerprint or key" />
Create SSH Keypair
createMutation.mutate(values))} className="space-y-4" > ( Name )} /> ( Comment )} /> ( Type )} /> ( Key size )} />
Name Public Key Fingerprint Created Actions {filtered.map((k) => { const keyType = getKeyType(k.public_key!) const truncated = truncateMiddle(k.public_key!, 18) return ( {k.name || "—"} {keyType}

{k.public_key}

{k.fingerprint} {k.created_at ? new Date(k.created_at).toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", }) : "—"}
) })} {filtered.length === 0 && ( No SSH Keys )}
{/* Reveal modal */} !o && setRevealFor(null)}> Private Key (read-only)
{revealFor?.name ?? "SSH key"}
{revealFor?.fingerprint}