import { useState, useEffect, useCallback } from "react"; import { Key, Plus, Trash2, CheckCircle, XCircle, Copy, Loader2, Terminal, Award, AlertTriangle, Pencil, ShieldOff, Server, Clock, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { useToast } from "@/hooks/use-toast"; import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api"; // ────────────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────────────── function formatDate(dateStr: string | null): string { if (!dateStr) return "—"; return new Date(dateStr).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", }); } function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const { toast } = useToast(); const handleCopy = async () => { try { await navigator.clipboard.writeText(text); setCopied(true); toast({ title: "Copied to clipboard" }); setTimeout(() => setCopied(false), 2000); } catch { toast({ variant: "destructive", title: "Copy failed" }); } }; return ( ); } // ────────────────────────────────────────────────────────────────────────────── // Main page component // ────────────────────────────────────────────────────────────────────────────── export default function SSHKeysPage() { const { toast } = useToast(); // Key list state const [keys, setKeys] = useState([]); const [isLoading, setIsLoading] = useState(true); // Add key modal const [showAdd, setShowAdd] = useState(false); const [addPublicKey, setAddPublicKey] = useState(""); const [addDescription, setAddDescription] = useState(""); const [isAdding, setIsAdding] = useState(false); const [addError, setAddError] = useState(null); // Delete confirmation const [deletingKey, setDeletingKey] = useState(null); const [isDeleting, setIsDeleting] = useState(false); // Inline description editing const [editingKeyId, setEditingKeyId] = useState(null); const [editingDescription, setEditingDescription] = useState(""); // Verify (challenge/sign) wizard const [verifyingKey, setVerifyingKey] = useState(null); const [verifyStep, setVerifyStep] = useState<"challenge" | "submit" | "done">("challenge"); const [challengeText, setChallengeText] = useState(""); const [signatureInput, setSignatureInput] = useState(""); const [isVerifying, setIsVerifying] = useState(false); const [verifyError, setVerifyError] = useState(null); // Sign certificate modal const [signingKey, setSigningKey] = useState(null); const [certResult, setCertResult] = useState(null); const [isSigning, setIsSigning] = useState(false); const [signError, setSignError] = useState(null); const [certType, setCertType] = useState<'user' | 'host'>('user'); const [expiryHours, setExpiryHours] = useState(''); const [deptCertPolicy, setDeptCertPolicy] = useState(null); // Principal selection (populated when sign dialog opens) const [principalOrgs, setPrincipalOrgs] = useState([]); const [availablePrincipals, setAvailablePrincipals] = useState([]); const [selectedPrincipalNames, setSelectedPrincipalNames] = useState>(new Set()); const [isLoadingPrincipals, setIsLoadingPrincipals] = useState(false); const [isAdminMode, setIsAdminMode] = useState(false); // Certificates tab const [certs, setCerts] = useState([]); const [isCertsLoading, setIsCertsLoading] = useState(false); const [revokingCertId, setRevokingCertId] = useState(null); const [isRevoking, setIsRevoking] = useState(false); // CA public key const [caPublicKey, setCaPublicKey] = useState(null); const [caName, setCaName] = useState(null); const [isCaLoading, setIsCaLoading] = useState(false); // ── Fetch keys ────────────────────────────────────────────────────────────── const fetchKeys = useCallback(async () => { setIsLoading(true); try { const data = await api.ssh.listKeys(); setKeys(data.keys); } catch (err) { const errorMsg = err instanceof ApiError ? `${err.message} (${err.type})` : String(err); console.error("Failed to load SSH keys:", errorMsg, err); toast({ variant: "destructive", title: "Failed to load SSH keys", description: errorMsg, }); } finally { setIsLoading(false); } }, [toast]); useEffect(() => { fetchKeys(); }, [fetchKeys]); const fetchCerts = useCallback(async () => { setIsCertsLoading(true); try { const data = await api.ssh.listCertificates(); setCerts(data.certificates); } catch (err) { console.error("Failed to load certificates:", err); } finally { setIsCertsLoading(false); } }, []); const fetchCaPublicKey = useCallback(async () => { setIsCaLoading(true); try { const data = await api.ssh.getCaPublicKey(); setCaPublicKey(data.public_key); setCaName(data.ca_name); } catch { // No CA configured — silently ignore } finally { setIsCaLoading(false); } }, []); // ── Add key ───────────────────────────────────────────────────────────────── const handleAdd = async () => { setAddError(null); if (!addPublicKey.trim()) { setAddError("Public key is required"); return; } setIsAdding(true); try { await api.ssh.addKey(addPublicKey.trim(), addDescription.trim() || undefined); toast({ title: "SSH key added" }); setShowAdd(false); setAddPublicKey(""); setAddDescription(""); fetchKeys(); } catch (err) { console.error("Failed to add SSH key:", err); setAddError(err instanceof ApiError ? err.message : "Failed to add key"); } finally { setIsAdding(false); } }; // ── Delete key ────────────────────────────────────────────────────────────── const handleDelete = async () => { if (!deletingKey) return; setIsDeleting(true); try { await api.ssh.deleteKey(deletingKey.id); setKeys((prev) => prev.filter((k) => k.id !== deletingKey.id)); toast({ title: "SSH key deleted" }); } catch (err) { toast({ variant: "destructive", title: "Failed to delete SSH key", description: err instanceof ApiError ? err.message : "An error occurred", }); } finally { setIsDeleting(false); setDeletingKey(null); } }; // ── Rename description ────────────────────────────────────────────────────── const handleRenameCommit = async (key: SSHKey) => { if (!editingDescription.trim() || editingDescription === (key.description ?? "")) { setEditingKeyId(null); return; } try { const updated = await api.ssh.updateKeyDescription(key.id, editingDescription.trim()); setKeys((prev) => prev.map((k) => (k.id === key.id ? updated : k))); toast({ title: "Description updated" }); } catch (err) { toast({ variant: "destructive", title: "Failed to update description", description: err instanceof ApiError ? err.message : "An error occurred", }); } finally { setEditingKeyId(null); } }; // ── Verify wizard ──────────────────────────────────────────────────────────── const startVerify = async (key: SSHKey) => { setVerifyingKey(key); setVerifyStep("challenge"); setChallengeText(""); setSignatureInput(""); setVerifyError(null); setIsVerifying(true); try { const data = await api.ssh.getChallenge(key.id); setChallengeText(data.challenge_text); setVerifyStep("submit"); } catch (err) { setVerifyError(err instanceof ApiError ? err.message : "Failed to fetch challenge"); } finally { setIsVerifying(false); } }; const handleVerifySubmit = async () => { if (!verifyingKey) return; setVerifyError(null); if (!signatureInput.trim()) { setVerifyError("Signature is required"); return; } setIsVerifying(true); try { const result = await api.ssh.verifyKey(verifyingKey.id, signatureInput.trim()); if (result.verified) { setVerifyStep("done"); setKeys((prev) => prev.map((k) => k.id === verifyingKey.id ? { ...k, verified: true, verified_at: new Date().toISOString() } : k ) ); } else { setVerifyError("Signature verification failed. Please check the signed data and try again."); } } catch (err) { setVerifyError(err instanceof ApiError ? err.message : "Verification failed"); } finally { setIsVerifying(false); } }; // ── Sign certificate ───────────────────────────────────────────────────────── const startSign = async (key: SSHKey) => { setSigningKey(key); setCertResult(null); setSignError(null); setSelectedPrincipalNames(new Set()); setAvailablePrincipals([]); setPrincipalOrgs([]); setIsAdminMode(false); setCertType('user'); setExpiryHours(''); setDeptCertPolicy(null); setIsLoadingPrincipals(true); // Fetch dept cert policy in parallel api.ssh.getMyDeptCertPolicy().then((data) => { setDeptCertPolicy(data.policy); }).catch(() => {/* non-fatal */}); try { const data = await api.users.myPrincipals(); setPrincipalOrgs(data.orgs); // Determine admin mode: user is admin in at least one org const adminOrg = data.orgs.find(o => o.is_admin); const isAdmin = !!adminOrg; setIsAdminMode(isAdmin); // Collect available options: admins see all_principals (full org list), // regular users see only my_principals (their assigned ones) const opts: PrincipalOption[] = []; const seen = new Set(); for (const org of data.orgs) { const list = isAdmin ? org.all_principals : org.my_principals; for (const p of list) { if (!seen.has(p.name)) { seen.add(p.name); opts.push(p); } } } setAvailablePrincipals(opts); // Pre-select all assigned principals (my_principals) regardless of admin status const preselected = new Set(); for (const org of data.orgs) { for (const p of org.my_principals) preselected.add(p.name); } setSelectedPrincipalNames(preselected); } catch (err) { setSignError(err instanceof ApiError ? err.message : "Failed to load principals"); } finally { setIsLoadingPrincipals(false); } }; const togglePrincipal = (name: string) => { setSelectedPrincipalNames(prev => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); }; const handleSign = async () => { if (!signingKey) return; setSignError(null); setIsSigning(true); try { const principals = Array.from(selectedPrincipalNames); const parsedExpiry = expiryHours.trim() ? parseInt(expiryHours, 10) : undefined; const result = await api.ssh.signCertificate( signingKey.id, principals.length > 0 ? principals : undefined, certType, parsedExpiry, ); setCertResult(result.certificate); fetchCerts(); // refresh certs list } catch (err) { setSignError(err instanceof ApiError ? err.message : "Certificate signing failed"); } finally { setIsSigning(false); } }; // ── Revoke certificate ─────────────────────────────────────────────────────── const handleRevoke = async () => { if (!revokingCertId) return; setIsRevoking(true); try { await api.ssh.revokeCertificate(revokingCertId); setCerts((prev) => prev.map((c) => (c.id === revokingCertId ? { ...c, revoked: true, status: "revoked" } : c)) ); toast({ title: "Certificate revoked" }); } catch (err) { toast({ variant: "destructive", title: "Failed to revoke certificate", description: err instanceof ApiError ? err.message : "An error occurred", }); } finally { setIsRevoking(false); setRevokingCertId(null); } }; // ────────────────────────────────────────────────────────────────────────────── // Render // ────────────────────────────────────────────────────────────────────────────── return (

SSH Keys

Manage your SSH public keys and request signed certificates

{ if (v === "certs") fetchCerts(); if (v === "ca") fetchCaPublicKey(); }}> Public Keys Certificates CA Public Key {/* ── Keys tab ──────────────────────────────────────────────────────── */} Your SSH Keys Public keys associated with your account. Verify a key to enable certificate signing. {isLoading ? (
) : keys.length === 0 ? (

No SSH keys yet

Add your first public key to get started

) : (
{keys.map((key) => (
{/* Description row */}
{editingKeyId === key.id ? ( setEditingDescription(e.target.value)} onBlur={() => handleRenameCommit(key)} onKeyDown={(e) => { if (e.key === "Enter") handleRenameCommit(key); if (e.key === "Escape") setEditingKeyId(null); }} className="h-7 text-sm max-w-xs" autoFocus /> ) : ( {key.description || No description} )} {key.verified ? ( Verified ) : ( Unverified )}
{/* Key fingerprint / type */}
{key.key_type && ( {key.key_type} )} {key.fingerprint ?? key.public_key.slice(0, 64) + "…"}
{/* Dates */}
Added {formatDate(key.created_at)} {key.verified_at && · Verified {formatDate(key.verified_at)}}
{/* Actions */}
{!key.verified && ( )} {key.verified && ( )}
))}
)}
{/* ── Certificates tab ───────────────────────────────────────────────── */} Issued Certificates SSH certificates issued to your keys. Active certificates can be used to authenticate to servers. {isCertsLoading ? (
) : certs.length === 0 ? (

No certificates yet

Verify a key and click "Sign cert" to get your first certificate

) : (
{certs.map((cert) => { // Ensure the date string is treated as UTC regardless of whether // the backend emits a trailing Z (older rows may lack it). const validBeforeStr = cert.valid_before.endsWith("Z") || cert.valid_before.includes("+") ? cert.valid_before : cert.valid_before + "Z"; const isExpired = new Date(validBeforeStr) < new Date(); const isRevoked = cert.revoked; return (
{cert.principals.join(", ")} {isRevoked ? ( Revoked ) : isExpired ? ( Expired ) : ( Active )}
Valid {formatDate(cert.valid_after)} → {formatDate(cert.valid_before)} {cert.serial != null && · Serial #{cert.serial}}
{!isRevoked && !isExpired && ( )}
); })}
)}
{/* ── CA Public Key tab ──────────────────────────────────────────────── */} CA Public Key Add this key to TrustedUserCAKeys on your servers so they accept certificates issued by Gatehouse. {isCaLoading ? (
) : !caPublicKey ? (

No CA configured for your organization

) : (
{caName && (

CA: {caName}

)}