Files
gatehouse-ui/src/pages/user/SSHKeysPage.tsx
T

1047 lines
44 KiB
TypeScript
Raw Normal View History

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>
);
}