1047 lines
44 KiB
TypeScript
1047 lines
44 KiB
TypeScript
|
|
import { useState, useEffect, useCallback } from "react";
|
||
|
|
import {
|
||
|
|
Key,
|
||
|
|
Plus,
|
||
|
|
Trash2,
|
||
|
|
CheckCircle,
|
||
|
|
XCircle,
|
||
|
|
Copy,
|
||
|
|
Loader2,
|
||
|
|
Terminal,
|
||
|
|
Award,
|
||
|
|
AlertTriangle,
|
||
|
|
Pencil,
|
||
|
|
ShieldOff,
|
||
|
|
Server,
|
||
|
|
} 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 } 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 (
|
||
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 flex-shrink-0" onClick={handleCopy}>
|
||
|
|
{copied ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||
|
|
</Button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
// Main page component
|
||
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export default function SSHKeysPage() {
|
||
|
|
const { toast } = useToast();
|
||
|
|
|
||
|
|
// Key list state
|
||
|
|
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||
|
|
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<string | null>(null);
|
||
|
|
|
||
|
|
// Delete confirmation
|
||
|
|
const [deletingKey, setDeletingKey] = useState<SSHKey | null>(null);
|
||
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
||
|
|
|
||
|
|
// Inline description editing
|
||
|
|
const [editingKeyId, setEditingKeyId] = useState<string | null>(null);
|
||
|
|
const [editingDescription, setEditingDescription] = useState("");
|
||
|
|
|
||
|
|
// Verify (challenge/sign) wizard
|
||
|
|
const [verifyingKey, setVerifyingKey] = useState<SSHKey | null>(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<string | null>(null);
|
||
|
|
|
||
|
|
// Sign certificate modal
|
||
|
|
const [signingKey, setSigningKey] = useState<SSHKey | null>(null);
|
||
|
|
const [certResult, setCertResult] = useState<string | null>(null);
|
||
|
|
const [isSigning, setIsSigning] = useState(false);
|
||
|
|
const [signError, setSignError] = useState<string | null>(null);
|
||
|
|
const [certType, setCertType] = useState<'user' | 'host'>('user');
|
||
|
|
const [expiryHours, setExpiryHours] = useState<string>('');
|
||
|
|
|
||
|
|
// Principal selection (populated when sign dialog opens)
|
||
|
|
const [principalOrgs, setPrincipalOrgs] = useState<MyPrincipalsOrg[]>([]);
|
||
|
|
const [availablePrincipals, setAvailablePrincipals] = useState<PrincipalOption[]>([]);
|
||
|
|
const [selectedPrincipalNames, setSelectedPrincipalNames] = useState<Set<string>>(new Set());
|
||
|
|
const [isLoadingPrincipals, setIsLoadingPrincipals] = useState(false);
|
||
|
|
const [isAdminMode, setIsAdminMode] = useState(false);
|
||
|
|
|
||
|
|
// Certificates tab
|
||
|
|
const [certs, setCerts] = useState<SSHCertificate[]>([]);
|
||
|
|
const [isCertsLoading, setIsCertsLoading] = useState(false);
|
||
|
|
const [revokingCertId, setRevokingCertId] = useState<string | null>(null);
|
||
|
|
const [isRevoking, setIsRevoking] = useState(false);
|
||
|
|
|
||
|
|
// CA public key
|
||
|
|
const [caPublicKey, setCaPublicKey] = useState<string | null>(null);
|
||
|
|
const [caName, setCaName] = useState<string | null>(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('');
|
||
|
|
setIsLoadingPrincipals(true);
|
||
|
|
|
||
|
|
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<string>();
|
||
|
|
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<string>();
|
||
|
|
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 (
|
||
|
|
<div className="page-container">
|
||
|
|
<div className="page-header">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h1 className="page-title">SSH Keys</h1>
|
||
|
|
<p className="page-description">
|
||
|
|
Manage your SSH public keys and request signed certificates
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Button onClick={() => setShowAdd(true)}>
|
||
|
|
<Plus className="w-4 h-4 mr-2" />
|
||
|
|
Add SSH key
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Tabs defaultValue="keys" onValueChange={(v) => {
|
||
|
|
if (v === "certs") fetchCerts();
|
||
|
|
if (v === "ca") fetchCaPublicKey();
|
||
|
|
}}>
|
||
|
|
<TabsList className="mb-4">
|
||
|
|
<TabsTrigger value="keys">Public Keys</TabsTrigger>
|
||
|
|
<TabsTrigger value="certs">Certificates</TabsTrigger>
|
||
|
|
<TabsTrigger value="ca">CA Public Key</TabsTrigger>
|
||
|
|
</TabsList>
|
||
|
|
|
||
|
|
{/* ── Keys tab ──────────────────────────────────────────────────────── */}
|
||
|
|
<TabsContent value="keys">
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-base flex items-center gap-2">
|
||
|
|
<Key className="w-4 h-4" />
|
||
|
|
Your SSH Keys
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Public keys associated with your account. Verify a key to enable certificate signing.
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{isLoading ? (
|
||
|
|
<div className="flex items-center justify-center py-12">
|
||
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
) : keys.length === 0 ? (
|
||
|
|
<div className="text-center py-12 text-muted-foreground">
|
||
|
|
<Key className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
||
|
|
<p className="text-sm font-medium">No SSH keys yet</p>
|
||
|
|
<p className="text-xs mt-1">Add your first public key to get started</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-3">
|
||
|
|
{keys.map((key) => (
|
||
|
|
<div
|
||
|
|
key={key.id}
|
||
|
|
className="flex items-start justify-between gap-3 p-4 border rounded-lg"
|
||
|
|
>
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
{/* Description row */}
|
||
|
|
<div className="flex items-center gap-2 mb-1">
|
||
|
|
{editingKeyId === key.id ? (
|
||
|
|
<Input
|
||
|
|
value={editingDescription}
|
||
|
|
onChange={(e) => 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
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<span className="text-sm font-medium">
|
||
|
|
{key.description || <span className="text-muted-foreground italic">No description</span>}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-6 w-6"
|
||
|
|
onClick={() => {
|
||
|
|
setEditingKeyId(key.id);
|
||
|
|
setEditingDescription(key.description ?? "");
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Pencil className="w-3 h-3" />
|
||
|
|
</Button>
|
||
|
|
{key.verified ? (
|
||
|
|
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">
|
||
|
|
<CheckCircle className="w-3 h-3 mr-1" />
|
||
|
|
Verified
|
||
|
|
</Badge>
|
||
|
|
) : (
|
||
|
|
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
|
||
|
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
||
|
|
Unverified
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Key fingerprint / type */}
|
||
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono truncate">
|
||
|
|
{key.key_type && (
|
||
|
|
<span className="bg-muted px-1.5 py-0.5 rounded text-[10px] uppercase font-sans">
|
||
|
|
{key.key_type}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
<span className="truncate">{key.fingerprint ?? key.public_key.slice(0, 64) + "…"}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Dates */}
|
||
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
||
|
|
Added {formatDate(key.created_at)}
|
||
|
|
{key.verified_at && <span> · Verified {formatDate(key.verified_at)}</span>}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Actions */}
|
||
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||
|
|
{!key.verified && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="text-xs"
|
||
|
|
onClick={() => startVerify(key)}
|
||
|
|
>
|
||
|
|
Verify
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
{key.verified && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="text-xs"
|
||
|
|
onClick={() => startSign(key)}
|
||
|
|
>
|
||
|
|
<Award className="w-3 h-3 mr-1" />
|
||
|
|
Sign cert
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||
|
|
onClick={() => setDeletingKey(key)}
|
||
|
|
>
|
||
|
|
<Trash2 className="w-3.5 h-3.5" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* ── Certificates tab ───────────────────────────────────────────────── */}
|
||
|
|
<TabsContent value="certs">
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-base flex items-center gap-2">
|
||
|
|
<Award className="w-4 h-4" />
|
||
|
|
Issued Certificates
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
SSH certificates issued to your keys. Active certificates can be used to authenticate to servers.
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{isCertsLoading ? (
|
||
|
|
<div className="flex items-center justify-center py-12">
|
||
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
) : certs.length === 0 ? (
|
||
|
|
<div className="text-center py-12 text-muted-foreground">
|
||
|
|
<Award className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
||
|
|
<p className="text-sm font-medium">No certificates yet</p>
|
||
|
|
<p className="text-xs mt-1">Verify a key and click "Sign cert" to get your first certificate</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-3">
|
||
|
|
{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 (
|
||
|
|
<div key={cert.id} className="flex items-start justify-between gap-3 p-4 border rounded-lg">
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div className="flex items-center gap-2 mb-1">
|
||
|
|
<span className="text-sm font-medium font-mono truncate">
|
||
|
|
{cert.principals.join(", ")}
|
||
|
|
</span>
|
||
|
|
{isRevoked ? (
|
||
|
|
<Badge variant="destructive" className="text-xs">Revoked</Badge>
|
||
|
|
) : isExpired ? (
|
||
|
|
<Badge variant="outline" className="text-xs text-muted-foreground">Expired</Badge>
|
||
|
|
) : (
|
||
|
|
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">Active</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="text-xs text-muted-foreground">
|
||
|
|
Valid {formatDate(cert.valid_after)} → {formatDate(cert.valid_before)}
|
||
|
|
{cert.serial != null && <span> · Serial #{cert.serial}</span>}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{!isRevoked && !isExpired && (
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
className="text-xs text-destructive hover:text-destructive flex-shrink-0"
|
||
|
|
onClick={() => setRevokingCertId(cert.id)}
|
||
|
|
>
|
||
|
|
<ShieldOff className="w-3 h-3 mr-1" />
|
||
|
|
Revoke
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* ── CA Public Key tab ──────────────────────────────────────────────── */}
|
||
|
|
<TabsContent value="ca">
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-base flex items-center gap-2">
|
||
|
|
<Server className="w-4 h-4" />
|
||
|
|
CA Public Key
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Add this key to <code>TrustedUserCAKeys</code> on your servers so they accept certificates issued by Gatehouse.
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{isCaLoading ? (
|
||
|
|
<div className="flex items-center justify-center py-8">
|
||
|
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
) : !caPublicKey ? (
|
||
|
|
<div className="text-center py-8 text-muted-foreground">
|
||
|
|
<Server className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
||
|
|
<p className="text-sm">No CA configured for your organization</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{caName && (
|
||
|
|
<p className="text-sm text-muted-foreground">CA: <strong>{caName}</strong></p>
|
||
|
|
)}
|
||
|
|
<div className="relative">
|
||
|
|
<Textarea
|
||
|
|
readOnly
|
||
|
|
value={caPublicKey}
|
||
|
|
className="font-mono text-xs min-h-[80px] pr-10"
|
||
|
|
/>
|
||
|
|
<div className="absolute top-2 right-2">
|
||
|
|
<CopyButton text={caPublicKey} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="rounded-lg bg-muted p-3 space-y-1">
|
||
|
|
<p className="text-xs font-semibold flex items-center gap-1">
|
||
|
|
<Terminal className="w-3 h-3" /> Server setup
|
||
|
|
</p>
|
||
|
|
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
|
||
|
|
{`# On each SSH server:
|
||
|
|
echo '<ca_public_key>' >> /etc/ssh/trusted_user_ca_keys
|
||
|
|
# In /etc/ssh/sshd_config:
|
||
|
|
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys`}
|
||
|
|
</pre>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</TabsContent>
|
||
|
|
</Tabs>
|
||
|
|
|
||
|
|
{/* ── Add key dialog ────────────────────────────────────────────────────── */}
|
||
|
|
<Dialog open={showAdd} onOpenChange={(open) => { setShowAdd(open); setAddError(null); }}>
|
||
|
|
<DialogContent className="sm:max-w-lg">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Add SSH Public Key</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Paste your SSH public key (e.g. the contents of <code>~/.ssh/id_ed25519.pub</code>).
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="space-y-4 py-2">
|
||
|
|
{addError && (
|
||
|
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{addError}</div>
|
||
|
|
)}
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Public key</Label>
|
||
|
|
<Textarea
|
||
|
|
placeholder="ssh-ed25519 AAAA..."
|
||
|
|
value={addPublicKey}
|
||
|
|
onChange={(e) => setAddPublicKey(e.target.value)}
|
||
|
|
className="font-mono text-xs min-h-[100px]"
|
||
|
|
disabled={isAdding}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Description <span className="text-muted-foreground">(optional)</span></Label>
|
||
|
|
<Input
|
||
|
|
placeholder="My laptop key"
|
||
|
|
value={addDescription}
|
||
|
|
onChange={(e) => setAddDescription(e.target.value)}
|
||
|
|
disabled={isAdding}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={() => setShowAdd(false)} disabled={isAdding}>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleAdd} disabled={isAdding || !addPublicKey.trim()}>
|
||
|
|
{isAdding && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||
|
|
Add key
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
{/* ── Delete confirmation ───────────────────────────────────────────────── */}
|
||
|
|
<AlertDialog open={!!deletingKey} onOpenChange={() => setDeletingKey(null)}>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>Delete SSH key?</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
Are you sure you want to delete{" "}
|
||
|
|
<strong>{deletingKey?.description || "this key"}</strong>? This cannot be undone and any certificates signed with it will stop working.
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||
|
|
<AlertDialogAction
|
||
|
|
onClick={handleDelete}
|
||
|
|
disabled={isDeleting}
|
||
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||
|
|
>
|
||
|
|
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||
|
|
Delete
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
|
||
|
|
{/* ── Verify wizard dialog ──────────────────────────────────────────────── */}
|
||
|
|
<Dialog open={!!verifyingKey} onOpenChange={(open) => { if (!open) setVerifyingKey(null); }}>
|
||
|
|
<DialogContent className="sm:max-w-lg">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Verify SSH Key Ownership</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Prove you own this key by signing a challenge with it.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
{verifyStep === "challenge" && isVerifying && (
|
||
|
|
<div className="flex items-center justify-center py-8">
|
||
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||
|
|
<span className="ml-2 text-sm text-muted-foreground">Fetching challenge…</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{verifyStep === "challenge" && !isVerifying && verifyError && (
|
||
|
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{verifyError}</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{verifyStep === "submit" && (
|
||
|
|
<div className="space-y-4 py-2">
|
||
|
|
{verifyError && (
|
||
|
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{verifyError}</div>
|
||
|
|
)}
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Step 1 — Save this challenge text to a file</Label>
|
||
|
|
<div className="relative">
|
||
|
|
<Textarea
|
||
|
|
readOnly
|
||
|
|
value={challengeText}
|
||
|
|
className="font-mono text-xs min-h-[80px] pr-10"
|
||
|
|
/>
|
||
|
|
<div className="absolute top-2 right-2">
|
||
|
|
<CopyButton text={challengeText} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="rounded-lg bg-muted p-3 space-y-1">
|
||
|
|
<p className="text-xs font-semibold flex items-center gap-1">
|
||
|
|
<Terminal className="w-3 h-3" /> Step 2 — Sign with ssh-keygen
|
||
|
|
</p>
|
||
|
|
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
|
||
|
|
{`echo '<challenge_text>' > /tmp/challenge.txt
|
||
|
|
ssh-keygen -Y sign \\
|
||
|
|
-f ~/.ssh/id_ed25519 \\
|
||
|
|
-n gatehouse \\
|
||
|
|
/tmp/challenge.txt
|
||
|
|
cat /tmp/challenge.txt.sig | base64 -w0`}
|
||
|
|
</pre>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Step 3 — Paste the base64-encoded signature</Label>
|
||
|
|
<Textarea
|
||
|
|
placeholder="Paste base64 signature here…"
|
||
|
|
value={signatureInput}
|
||
|
|
onChange={(e) => setSignatureInput(e.target.value)}
|
||
|
|
className="font-mono text-xs min-h-[80px]"
|
||
|
|
disabled={isVerifying}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{verifyStep === "done" && (
|
||
|
|
<div className="flex flex-col items-center py-8 gap-3 text-center">
|
||
|
|
<CheckCircle className="w-12 h-12 text-green-500" />
|
||
|
|
<p className="font-medium">Key verified!</p>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
You can now use this key to request SSH certificates.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
{verifyStep !== "done" ? (
|
||
|
|
<>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => setVerifyingKey(null)}
|
||
|
|
disabled={isVerifying}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
{verifyStep === "submit" && (
|
||
|
|
<Button
|
||
|
|
onClick={handleVerifySubmit}
|
||
|
|
disabled={isVerifying || !signatureInput.trim()}
|
||
|
|
>
|
||
|
|
{isVerifying && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||
|
|
Verify signature
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<Button onClick={() => setVerifyingKey(null)}>Done</Button>
|
||
|
|
)}
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
{/* ── Sign certificate dialog ───────────────────────────────────────────── */}
|
||
|
|
<Dialog open={!!signingKey} onOpenChange={(open) => { if (!open) { setSigningKey(null); setCertResult(null); setSignError(null); } }}>
|
||
|
|
<DialogContent className="sm:max-w-xl">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Sign SSH Certificate</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Request a signed certificate for{" "}
|
||
|
|
<strong>{signingKey?.description || "this key"}</strong>.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
{signError && (
|
||
|
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{signError}</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{!certResult ? (
|
||
|
|
<div className="space-y-4 py-2">
|
||
|
|
{isLoadingPrincipals ? (
|
||
|
|
<div className="flex items-center justify-center py-6">
|
||
|
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||
|
|
<span className="ml-2 text-sm text-muted-foreground">Loading principals…</span>
|
||
|
|
</div>
|
||
|
|
) : availablePrincipals.length === 0 ? (
|
||
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-800 p-3 text-sm text-amber-800 dark:text-amber-200">
|
||
|
|
You have no principals assigned. Ask an admin to add you to a principal before requesting a certificate.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<Label className="text-sm font-medium">
|
||
|
|
{isAdminMode ? "Select principals" : "Select principals"}
|
||
|
|
</Label>
|
||
|
|
<div className="flex gap-2 text-xs">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="text-primary hover:underline"
|
||
|
|
onClick={() => setSelectedPrincipalNames(new Set(availablePrincipals.map(p => p.name)))}
|
||
|
|
>
|
||
|
|
All
|
||
|
|
</button>
|
||
|
|
<span className="text-muted-foreground">·</span>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="text-primary hover:underline"
|
||
|
|
onClick={() => setSelectedPrincipalNames(new Set())}
|
||
|
|
>
|
||
|
|
None
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="max-h-52 overflow-y-auto rounded-lg border divide-y">
|
||
|
|
{availablePrincipals.map((p) => {
|
||
|
|
const checked = selectedPrincipalNames.has(p.name);
|
||
|
|
// For regular users, my_principals are the ones they're assigned
|
||
|
|
const isAssigned = principalOrgs.some(o => o.my_principals.some(mp => mp.name === p.name));
|
||
|
|
return (
|
||
|
|
<label
|
||
|
|
key={p.id}
|
||
|
|
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer hover:bg-muted/50 transition-colors"
|
||
|
|
>
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
className="mt-0.5 accent-primary"
|
||
|
|
checked={checked}
|
||
|
|
onChange={() => togglePrincipal(p.name)}
|
||
|
|
/>
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-sm font-mono font-medium">{p.name}</span>
|
||
|
|
{isAssigned && !isAdminMode && (
|
||
|
|
<Badge className="text-[10px] bg-green-500/10 text-green-700 border-0 px-1.5 py-0">assigned</Badge>
|
||
|
|
)}
|
||
|
|
{isAdminMode && isAssigned && (
|
||
|
|
<Badge className="text-[10px] bg-blue-500/10 text-blue-700 border-0 px-1.5 py-0">your assignment</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{p.description && (
|
||
|
|
<p className="text-xs text-muted-foreground truncate">{p.description}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
{selectedPrincipalNames.size === 0
|
||
|
|
? "Select at least one principal"
|
||
|
|
: `${selectedPrincipalNames.size} principal${selectedPrincipalNames.size !== 1 ? "s" : ""} selected`}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Expiry hours override */}
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label htmlFor="expiry-hours" className="text-sm font-medium">
|
||
|
|
Validity (hours){' '}
|
||
|
|
<span className="text-muted-foreground font-normal">(optional — leave blank to use CA default)</span>
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="expiry-hours"
|
||
|
|
type="number"
|
||
|
|
min={1}
|
||
|
|
placeholder="e.g. 8"
|
||
|
|
value={expiryHours}
|
||
|
|
onChange={(e) => setExpiryHours(e.target.value)}
|
||
|
|
className="w-36"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-3 py-2">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<Label>Certificate</Label>
|
||
|
|
<CopyButton text={certResult} />
|
||
|
|
</div>
|
||
|
|
<Textarea
|
||
|
|
readOnly
|
||
|
|
value={certResult}
|
||
|
|
className="font-mono text-xs min-h-[140px]"
|
||
|
|
/>
|
||
|
|
<div className="rounded-lg bg-muted p-3">
|
||
|
|
<p className="text-xs font-semibold flex items-center gap-1 mb-1">
|
||
|
|
<Terminal className="w-3 h-3" /> How to use
|
||
|
|
</p>
|
||
|
|
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
|
||
|
|
{`# Save next to your private key, e.g.:
|
||
|
|
echo '<certificate>' > ~/.ssh/id_ed25519-cert.pub
|
||
|
|
ssh -i ~/.ssh/id_ed25519 user@host`}
|
||
|
|
</pre>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={() => { setSigningKey(null); setCertResult(null); }}>
|
||
|
|
Close
|
||
|
|
</Button>
|
||
|
|
{!certResult && (
|
||
|
|
<Button
|
||
|
|
onClick={handleSign}
|
||
|
|
disabled={isSigning || isLoadingPrincipals || selectedPrincipalNames.size === 0}
|
||
|
|
>
|
||
|
|
{isSigning && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||
|
|
Sign certificate
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
{/* ── Revoke certificate confirmation ───────────────────────────────────── */}
|
||
|
|
<AlertDialog open={!!revokingCertId} onOpenChange={() => setRevokingCertId(null)}>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>Revoke certificate?</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
This will permanently revoke the certificate. Any active SSH sessions using it will not be affected immediately, but no new authentications will be allowed.
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel disabled={isRevoking}>Cancel</AlertDialogCancel>
|
||
|
|
<AlertDialogAction
|
||
|
|
onClick={handleRevoke}
|
||
|
|
disabled={isRevoking}
|
||
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||
|
|
>
|
||
|
|
{isRevoking && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||
|
|
Revoke
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|