-
-
-
-
-
-
- {principal.name}
-
+ {filteredPrincipals.map((principal) => {
+ const linked = linkedDepts[principal.id] || [];
+ return (
+
+
+
- {principal.description && (
-
- {principal.description}
-
- )}
-
- Created {new Date(principal.created_at).toLocaleDateString()}
+
+
{principal.name}
+ {principal.description && (
+
{principal.description}
+ )}
+
+ {/* Linked department tags */}
+
+ {linked.length === 0 ? (
+ Not linked to any department
+ ) : linked.map((dept) => {
+ const key = `${principal.id}:${dept.id}`;
+ const busy = unlinkingKey === key;
+ return (
+
+ {dept.name}
+ handleUnlink(principal.id, dept.id)}
+ disabled={busy}
+ className="rounded-full p-0.5 hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50 transition-colors"
+ title="Unlink from department"
+ >
+ {busy ? : }
+
+
+ );
+ })}
+
+
+
+ Created {new Date(principal.created_at).toLocaleDateString()}
+
+
+
+
+
+
+
+
+
+ openEditDialog(principal)}>
+
+ Edit
+
+ openLinkDialog(principal)}>
+
+ Link to Department
+
+
+ handleDeletePrincipal(principal.id)}
+ >
+
+ Delete
+
+
+
-
-
-
-
-
-
-
- openEditDialog(principal)}>
-
- Edit
-
- openLinkDialog(principal)}>
-
- Link to Department
-
-
- handleDeletePrincipal(principal.id)}
- >
-
- Delete
-
-
-
-
- ))}
+ );
+ })}
)}
@@ -272,16 +314,14 @@ export default function PrincipalsPage() {
Create Principal
-
- Create a new principal to manage access and permissions
-
+ Create a new principal to manage access and permissions
- setIsCreateDialogOpen(false)}>
- Cancel
-
-
- Create Principal
-
+ setIsCreateDialogOpen(false)}>Cancel
+ Create Principal
@@ -313,16 +349,13 @@ export default function PrincipalsPage() {
Edit Principal
-
- Update principal information
-
+ Update principal information
- setIsEditDialogOpen(false)}>
- Cancel
-
-
- Save Changes
-
+ setIsEditDialogOpen(false)}>Cancel
+ Save Changes
- {/* Link Principal to Department Dialog */}
-
+ {/* Link to Department Dialog */}
+ {
+ if (!open) { setSelectedPrincipalForLink(null); setSelectedDepartmentId(""); }
+ setIsLinkDialogOpen(open);
+ }}
+ >
- Link Principal to Department
+ Link to Department
- Associate this principal with a department
+ Link {selectedPrincipalForLink?.name} to a department
-
Select Department
-
-
-
-
-
- {departments.map((dept) => (
-
- {dept.name}
-
- ))}
-
-
+
Department
+ {availableToLink.length === 0 ? (
+
Already linked to all available departments.
+ ) : (
+
+
+
+
+
+ {availableToLink.map((dept) => (
+ {dept.name}
+ ))}
+
+
+ )}
- setIsLinkDialogOpen(false)}>
- Cancel
-
-
- Link Principal
+ setIsLinkDialogOpen(false)}>Cancel
+
+ {isLinking && }
+ Link
);
-}
+}
\ No newline at end of file
diff --git a/src/pages/user/SSHKeysPage.tsx b/src/pages/user/SSHKeysPage.tsx
new file mode 100644
index 0000000..e3605ed
--- /dev/null
+++ b/src/pages/user/SSHKeysPage.tsx
@@ -0,0 +1,1046 @@
+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 (
+
+ {copied ? : }
+
+ );
+}
+
+// ──────────────────────────────────────────────────────────────────────────────
+// 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('');
+
+ // 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('');
+ 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();
+ 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
+
+
+
setShowAdd(true)}>
+
+ Add SSH key
+
+
+
+
+
{
+ 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 }
+
+ )}
+
{
+ setEditingKeyId(key.id);
+ setEditingDescription(key.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 && (
+
startVerify(key)}
+ >
+ Verify
+
+ )}
+ {key.verified && (
+
startSign(key)}
+ >
+
+ Sign cert
+
+ )}
+
setDeletingKey(key)}
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* ── 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 && (
+
setRevokingCertId(cert.id)}
+ >
+
+ Revoke
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* ── 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}
+ )}
+
+
+
+ Server setup
+
+
+{`# On each SSH server:
+echo '' >> /etc/ssh/trusted_user_ca_keys
+# In /etc/ssh/sshd_config:
+TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys`}
+
+
+
+ )}
+
+
+
+
+
+ {/* ── Add key dialog ────────────────────────────────────────────────────── */}
+
{ setShowAdd(open); setAddError(null); }}>
+
+
+ Add SSH Public Key
+
+ Paste your SSH public key (e.g. the contents of ~/.ssh/id_ed25519.pub).
+
+
+
+ {addError && (
+
{addError}
+ )}
+
+ Public key
+
+
+ Description (optional)
+ setAddDescription(e.target.value)}
+ disabled={isAdding}
+ />
+
+
+
+ setShowAdd(false)} disabled={isAdding}>
+ Cancel
+
+
+ {isAdding && }
+ Add key
+
+
+
+
+
+ {/* ── Delete confirmation ───────────────────────────────────────────────── */}
+
setDeletingKey(null)}>
+
+
+ Delete SSH key?
+
+ Are you sure you want to delete{" "}
+ {deletingKey?.description || "this key"} ? This cannot be undone and any certificates signed with it will stop working.
+
+
+
+ Cancel
+
+ {isDeleting && }
+ Delete
+
+
+
+
+
+ {/* ── Verify wizard dialog ──────────────────────────────────────────────── */}
+
{ if (!open) setVerifyingKey(null); }}>
+
+
+ Verify SSH Key Ownership
+
+ Prove you own this key by signing a challenge with it.
+
+
+
+ {verifyStep === "challenge" && isVerifying && (
+
+
+ Fetching challenge…
+
+ )}
+
+ {verifyStep === "challenge" && !isVerifying && verifyError && (
+ {verifyError}
+ )}
+
+ {verifyStep === "submit" && (
+
+ {verifyError && (
+
{verifyError}
+ )}
+
+
Step 1 — Save this challenge text to a file
+
+
+
+
+
+ Step 2 — Sign with ssh-keygen
+
+
+{`echo '' > /tmp/challenge.txt
+ssh-keygen -Y sign \\
+ -f ~/.ssh/id_ed25519 \\
+ -n gatehouse \\
+ /tmp/challenge.txt
+cat /tmp/challenge.txt.sig | base64 -w0`}
+
+
+
+
+ Step 3 — Paste the base64-encoded signature
+
+
+ )}
+
+ {verifyStep === "done" && (
+
+
+
Key verified!
+
+ You can now use this key to request SSH certificates.
+
+
+ )}
+
+
+ {verifyStep !== "done" ? (
+ <>
+ setVerifyingKey(null)}
+ disabled={isVerifying}
+ >
+ Cancel
+
+ {verifyStep === "submit" && (
+
+ {isVerifying && }
+ Verify signature
+
+ )}
+ >
+ ) : (
+ setVerifyingKey(null)}>Done
+ )}
+
+
+
+
+ {/* ── Sign certificate dialog ───────────────────────────────────────────── */}
+
{ if (!open) { setSigningKey(null); setCertResult(null); setSignError(null); } }}>
+
+
+ Sign SSH Certificate
+
+ Request a signed certificate for{" "}
+ {signingKey?.description || "this key"} .
+
+
+
+ {signError && (
+ {signError}
+ )}
+
+ {!certResult ? (
+
+ {isLoadingPrincipals ? (
+
+
+ Loading principals…
+
+ ) : availablePrincipals.length === 0 ? (
+
+ You have no principals assigned. Ask an admin to add you to a principal before requesting a certificate.
+
+ ) : (
+
+
+
+ {isAdminMode ? "Select principals" : "Select principals"}
+
+
+ setSelectedPrincipalNames(new Set(availablePrincipals.map(p => p.name)))}
+ >
+ All
+
+ ·
+ setSelectedPrincipalNames(new Set())}
+ >
+ None
+
+
+
+
+ {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 (
+
+ togglePrincipal(p.name)}
+ />
+
+
+ {p.name}
+ {isAssigned && !isAdminMode && (
+ assigned
+ )}
+ {isAdminMode && isAssigned && (
+ your assignment
+ )}
+
+ {p.description && (
+
{p.description}
+ )}
+
+
+ );
+ })}
+
+
+ {selectedPrincipalNames.size === 0
+ ? "Select at least one principal"
+ : `${selectedPrincipalNames.size} principal${selectedPrincipalNames.size !== 1 ? "s" : ""} selected`}
+
+
+ )}
+
+ {/* Expiry hours override */}
+
+
+ Validity (hours){' '}
+ (optional — leave blank to use CA default)
+
+ setExpiryHours(e.target.value)}
+ className="w-36"
+ />
+
+
+ ) : (
+
+
+ Certificate
+
+
+
+
+
+ How to use
+
+
+{`# Save next to your private key, e.g.:
+echo '' > ~/.ssh/id_ed25519-cert.pub
+ssh -i ~/.ssh/id_ed25519 user@host`}
+
+
+
+ )}
+
+
+ { setSigningKey(null); setCertResult(null); }}>
+ Close
+
+ {!certResult && (
+
+ {isSigning && }
+ Sign certificate
+
+ )}
+
+
+
+
+ {/* ── Revoke certificate confirmation ───────────────────────────────────── */}
+
setRevokingCertId(null)}>
+
+
+ Revoke certificate?
+
+ This will permanently revoke the certificate. Any active SSH sessions using it will not be affected immediately, but no new authentications will be allowed.
+
+
+
+ Cancel
+
+ {isRevoking && }
+ Revoke
+
+
+
+
+
+ );
+}