diff --git a/src/pages/org/CAsPage.tsx b/src/pages/org/CAsPage.tsx index 76715fb..a62f641 100644 --- a/src/pages/org/CAsPage.tsx +++ b/src/pages/org/CAsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { Shield, ShieldAlert, @@ -7,13 +7,13 @@ import { Loader2, Terminal, Plus, - Trash2, - Users, - Lock, User, Server, Settings, AlertCircle, + ServerCog, + RefreshCw, + ShieldOff, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -55,7 +55,7 @@ import { import { useToast } from "@/hooks/use-toast"; import { useParams } from "react-router-dom"; import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; -import { api, OrgCA, CAPermission, ApiError } from "@/lib/api"; +import { api, OrgCA, ApiError } from "@/lib/api"; function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); @@ -82,200 +82,18 @@ function formatDate(d: string | null) { return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } -// ────────────────────────────────────────────────────────────────────────────── - -interface PermissionsCardProps { - ca: OrgCA; -} - -function PermissionsCard({ ca }: PermissionsCardProps) { - const { toast } = useToast(); - const [perms, setPerms] = useState([]); - const [openToAll, setOpenToAll] = useState(true); - const [isLoading, setIsLoading] = useState(false); - const [showAdd, setShowAdd] = useState(false); - const [addEmail, setAddEmail] = useState(""); - const [addPermission, setAddPermission] = useState<"sign" | "admin">("sign"); - const [isAdding, setIsAdding] = useState(false); - const [addError, setAddError] = useState(null); - const [removingUserId, setRemovingUserId] = useState(null); - const [isRemoving, setIsRemoving] = useState(false); - - const fetchPerms = useCallback(async () => { - setIsLoading(true); - try { - const data = await api.ssh.listCaPermissions(ca.id); - setPerms(data.permissions); - setOpenToAll(data.open_to_all); - } catch { - // non-fatal - } finally { - setIsLoading(false); - } - }, [ca.id]); - - useEffect(() => { fetchPerms(); }, [fetchPerms]); - - const handleAdd = async () => { - setAddError(null); - if (!addEmail.trim()) { setAddError("Email is required"); return; } - setIsAdding(true); - try { - // Resolve user_id from email via org members search - // We pass the email as user_id — the backend expects a user UUID. - // To keep it simple, we pass the email; if the backend doesn't support - // lookup by email, the admin must use the user UUID directly. - await api.ssh.addCaPermission(ca.id, addEmail.trim(), addPermission); - toast({ title: "Permission granted" }); - setShowAdd(false); - setAddEmail(""); - fetchPerms(); - } catch (err) { - setAddError(err instanceof ApiError ? err.message : "Failed to add permission"); - } finally { - setIsAdding(false); - } - }; - - const handleRemove = async () => { - if (!removingUserId) return; - setIsRemoving(true); - try { - await api.ssh.removeCaPermission(ca.id, removingUserId); - setPerms((prev) => prev.filter((p) => p.user_id !== removingUserId)); - toast({ title: "Permission revoked" }); - } catch (err) { - toast({ variant: "destructive", title: "Failed to revoke permission", description: err instanceof ApiError ? err.message : "" }); - } finally { - setIsRemoving(false); - setRemovingUserId(null); - } - }; - - return ( - - -
-
- - - Access Control - - - {openToAll - ? "Open to all org members — add users below to restrict access" - : "Restricted — only listed users may sign certificates"} - -
- -
-
- - {isLoading ? ( -
- ) : perms.length === 0 ? ( -
- - {openToAll ? "No restrictions — all org members can sign" : "No users granted access"} -
- ) : ( -
- {perms.map((p) => ( -
-
-

{p.user_email ?? p.user_id}

- {p.permission} -
- -
- ))} -
- )} -
- - {/* Add permission dialog */} - { setShowAdd(o); setAddError(null); }}> - - - Grant CA Access - - Enter the user ID (UUID) to grant permission on {ca.name}. - - -
- {addError &&
{addError}
} -
- - setAddEmail(e.target.value)} disabled={isAdding} /> -
-
- - -
-
- - - - -
-
- - {/* Confirm revoke */} - setRemovingUserId(null)}> - - - Revoke access? - - This will remove the user's permission to sign certificates with this CA. - - - - Cancel - - {isRemoving && } - Revoke - - - - -
- ); -} - // ─── CA Detail Card ─────────────────────────────────────────────────────────── interface CADetailCardProps { ca: OrgCA; onEdit: (ca: OrgCA) => void; + onRotate: (ca: OrgCA) => void; + onDelete: (ca: OrgCA) => void; } -function CADetailCard({ ca, onEdit }: CADetailCardProps) { +function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardProps) { const isUser = ca.ca_type === "user"; + const isSystem = !!ca.is_system; const sshConfig = isUser ? `# /etc/ssh/sshd_config:\nTrustedUserCAKeys /etc/ssh/trusted_user_ca_keys\n\n# Add public key:\necho '${ca.public_key.trim()}' \\\n >> /etc/ssh/trusted_user_ca_keys` : `# /etc/ssh/sshd_config:\nHostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub\n\n# Add to known_hosts (clients):\n@cert-authority * ${ca.public_key.trim()}`; @@ -287,9 +105,14 @@ function CADetailCard({ ca, onEdit }: CADetailCardProps) {
- {isUser ? : } + {isSystem ? : isUser ? : } {ca.name} - {ca.is_active ? ( + {isSystem ? ( + + + System + + ) : ca.is_active ? ( Active ) : ( Inactive @@ -303,21 +126,23 @@ function CADetailCard({ ca, onEdit }: CADetailCardProps) {
- {/* Stats */} -
-
-

{ca.active_certs}

-

Active certs

+ {/* Stats — hidden for system CAs (we have no cert records for them) */} + {!isSystem && ( +
+
+

{ca.active_certs}

+

Active certs

+
+
+

{ca.total_certs}

+

Total issued

+
+
+

{ca.default_cert_validity_hours}h

+

Default validity

+
-
-

{ca.total_certs}

-

Total issued

-
-
-

{ca.default_cert_validity_hours}h

-

Default validity

-
-
+ )} {/* Fingerprint */}
@@ -343,19 +168,41 @@ function CADetailCard({ ca, onEdit }: CADetailCardProps) {
{sshConfig}
-

Created {formatDate(ca.created_at)}

+ {ca.created_at && ( +

Created {formatDate(ca.created_at)}

+ )} + {ca.rotated_at && ( +

+ Key rotated {formatDate(ca.rotated_at)} + {ca.rotation_reason && <> — {ca.rotation_reason}} +

+ )} -
- -
+ {!isSystem && ( +
+ +
+ + +
+
+ )} - - {/* Permissions — user CAs only */} - {isUser && }
); } @@ -367,15 +214,18 @@ interface CASectionProps { ca: OrgCA | null; onCreateClick: (caType: "user" | "host") => void; onEdit: (ca: OrgCA) => void; + onRotate: (ca: OrgCA) => void; + onDelete: (ca: OrgCA) => void; } -function CASection({ caType, ca, onCreateClick, onEdit }: CASectionProps) { +function CASection({ caType, ca, onCreateClick, onEdit, onRotate, onDelete }: CASectionProps) { const isUser = caType === "user"; const title = isUser ? "User Signing Key" : "Host Signing Key"; const subtitle = isUser ? "Signs SSH user certificates so users can authenticate to servers." : "Signs SSH host certificates so clients can verify server identity."; const Icon = isUser ? User : Server; + const isSystem = !!ca?.is_system; return (
@@ -383,7 +233,14 @@ function CASection({ caType, ca, onCreateClick, onEdit }: CASectionProps) {

{title}

{ca ? ( - Configured + isSystem ? ( + + + System (read-only) + + ) : ( + Configured + ) ) : ( @@ -393,7 +250,27 @@ function CASection({ caType, ca, onCreateClick, onEdit }: CASectionProps) {
{ca ? ( - + <> + + {/* When only a system CA is present, offer to generate a managed replacement */} + {isSystem && ( +
+ +
+

Using server-configured CA

+

+ Certificates are being signed by a CA key loaded from the server configuration, + not managed through this UI. Generate a managed key below to take full control + of certificate issuance from Gatehouse. +

+
+ +
+ )} + ) : ( @@ -445,6 +322,19 @@ export default function CAsPage() { const [isEditSaving, setIsEditSaving] = useState(false); const [editError, setEditError] = useState(null); + // Rotate CA dialog + const [rotatingCA, setRotatingCA] = useState(null); + const [isRotateDialogOpen, setIsRotateDialogOpen] = useState(false); + const [rotateKeyType, setRotateKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519"); + const [rotateReason, setRotateReason] = useState(""); + const [isRotating, setIsRotating] = useState(false); + const [rotateError, setRotateError] = useState(null); + + // Delete CA dialog + const [deletingCA, setDeletingCA] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + useEffect(() => { if (!orgId) { setIsLoading(false); return; } (async () => { @@ -549,7 +439,59 @@ export default function CAsPage() { } }; - return ( + // ── Rotate handlers ── + const handleRotateCA = (ca: OrgCA) => { + setRotatingCA(ca); + setRotateKeyType((ca.key_type as "ed25519" | "rsa" | "ecdsa") || "ed25519"); + setRotateReason(""); + setRotateError(null); + setIsRotateDialogOpen(true); + }; + + const handleConfirmRotate = async () => { + if (!orgId || !rotatingCA) return; + setIsRotating(true); + setRotateError(null); + try { + const result = await api.organizations.rotateCA(orgId, rotatingCA.id, { + key_type: rotateKeyType, + reason: rotateReason.trim() || undefined, + }); + setCAs(cas.map((ca) => (ca.id === rotatingCA.id ? result.ca : ca))); + setIsRotateDialogOpen(false); + setRotatingCA(null); + toast({ + title: "CA key rotated successfully", + description: `Old fingerprint: ${result.old_fingerprint}. Update TrustedUserCAKeys / known_hosts on your servers.`, + }); + } catch (err) { + setRotateError(err instanceof ApiError ? err.message : "Failed to rotate CA key"); + } finally { + setIsRotating(false); + } + }; + + // ── Delete handlers ── + const handleDeleteCA = (ca: OrgCA) => { + setDeletingCA(ca); + setIsDeleteDialogOpen(true); + }; + + const handleConfirmDelete = async () => { + if (!orgId || !deletingCA) return; + setIsDeleting(true); + try { + await api.organizations.deleteCA(orgId, deletingCA.id); + setCAs(cas.filter((ca) => ca.id !== deletingCA.id)); + setIsDeleteDialogOpen(false); + setDeletingCA(null); + toast({ title: "CA deleted", description: "Existing certificates remain valid until they expire." }); + } catch (err) { + toast({ variant: "destructive", title: "Failed to delete CA", description: err instanceof ApiError ? err.message : "" }); + } finally { + setIsDeleting(false); + } + }; return (
@@ -566,9 +508,9 @@ export default function CAsPage() {
) : (
- +
- +
)} @@ -722,7 +664,118 @@ export default function CAsPage() { + + {/* ── Rotate CA Dialog ── */} + { setIsRotateDialogOpen(open); if (!open) setRotateError(null); }}> + + + + + Rotate CA Key + + + Generate a new key pair for {rotatingCA?.name}. + Previously-issued certificates remain valid until they expire, but all new + certificates will be signed with the new key. You must update + {rotatingCA?.ca_type === "user" + ? " TrustedUserCAKeys on your SSH servers" + : " @cert-authority in client known_hosts files"} after rotation. + + + +
+ {rotateError && ( +
+ + {rotateError} +
+ )} + + {rotatingCA && ( +
+

⚠ Important

+

+ Current fingerprint: {rotatingCA.fingerprint} +

+

+ After rotation, you must replace this fingerprint on every server / + client that trusts this CA. Until updated, new certificates won't be accepted. +

+
+ )} + +
+ + +
+ +
+ + setRotateReason(e.target.value)} + disabled={isRotating} + /> +
+
+ + + + + +
+
+ + {/* ── Delete CA Confirmation ── */} + + + + Delete Certificate Authority? + + This will permanently deactivate {deletingCA?.name}. + No new certificates can be signed with this CA after deletion. + Existing certificates remain valid until they expire. + {deletingCA?.active_certs ? ( + + ⚠ This CA has {deletingCA.active_certs} active certificate{deletingCA.active_certs !== 1 ? "s" : ""}. + + ) : null} + + + + Cancel + + {isDeleting && } + Delete CA + + + +
); } -