Files
Charon/frontend/src/components/CertificateList.tsx

208 lines
7.9 KiB
TypeScript

import { useState, useMemo } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import { useCertificates } from '../hooks/useCertificates'
import { deleteCertificate } from '../api/certificates'
import { useProxyHosts } from '../hooks/useProxyHosts'
import { createBackup } from '../api/backups'
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
import { toast } from '../utils/toast'
type SortColumn = 'name' | 'expires'
type SortDirection = 'asc' | 'desc'
export default function CertificateList() {
const { certificates, isLoading, error } = useCertificates()
const { hosts } = useProxyHosts()
const queryClient = useQueryClient()
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
const deleteMutation = useMutation({
// Perform backup before actual deletion
mutationFn: async (id: number) => {
await createBackup()
await deleteCertificate(id)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
toast.success('Certificate deleted')
},
onError: (error: Error) => {
toast.error(`Failed to delete certificate: ${error.message}`)
},
})
const sortedCertificates = useMemo(() => {
return [...certificates].sort((a, b) => {
let comparison = 0
switch (sortColumn) {
case 'name': {
const aName = (a.name || a.domain || '').toLowerCase()
const bName = (b.name || b.domain || '').toLowerCase()
comparison = aName.localeCompare(bName)
break
}
case 'expires': {
const aDate = new Date(a.expires_at).getTime()
const bDate = new Date(b.expires_at).getTime()
comparison = aDate - bDate
break
}
}
return sortDirection === 'asc' ? comparison : -comparison
})
}, [certificates, sortColumn, sortDirection])
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDirection('asc')
}
}
const SortIcon = ({ column }: { column: SortColumn }) => {
if (sortColumn !== column) return null
return sortDirection === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
}
if (isLoading) return <LoadingSpinner />
if (error) return <div className="text-red-500">Failed to load certificates</div>
return (
<>
{deleteMutation.isPending && (
<ConfigReloadOverlay
message="Returning to shore..."
submessage="Certificate departure in progress"
type="charon"
/>
)}
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-gray-400">
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
<tr>
<th
onClick={() => handleSort('name')}
className="px-6 py-3 cursor-pointer hover:text-white transition-colors"
>
<div className="flex items-center gap-1">
Name
<SortIcon column="name" />
</div>
</th>
<th className="px-6 py-3">Domain</th>
<th className="px-6 py-3">Issuer</th>
<th
onClick={() => handleSort('expires')}
className="px-6 py-3 cursor-pointer hover:text-white transition-colors"
>
<div className="flex items-center gap-1">
Expires
<SortIcon column="expires" />
</div>
</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{certificates.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
No certificates found.
</td>
</tr>
) : (
sortedCertificates.map((cert) => (
<tr key={cert.id || cert.domain} className="hover:bg-gray-800/50 transition-colors">
<td className="px-6 py-4 font-medium text-white">{cert.name || '-'}</td>
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<span>{cert.issuer}</span>
{cert.issuer?.toLowerCase().includes('staging') && (
<span className="px-2 py-0.5 text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 rounded">
STAGING
</span>
)}
</div>
</td>
<td className="px-6 py-4">
{new Date(cert.expires_at).toLocaleDateString()}
</td>
<td className="px-6 py-4">
<StatusBadge status={cert.status} />
</td>
<td className="px-6 py-4">
{cert.id && (cert.provider === 'custom' || cert.issuer?.toLowerCase().includes('staging')) && (
<button
onClick={() => {
// Determine if certificate is in use by any proxy host
const inUse = hosts.some(h => {
const cid = h.certificate_id ?? h.certificate?.id
return cid === cert.id
})
if (inUse) {
toast.error('Certificate cannot be deleted because it is in use by a proxy host')
return
}
// Allow deletion for custom/staging certs not in use (status check removed)
const message = cert.provider === 'custom'
? 'Are you sure you want to delete this certificate? This will create a backup before deleting.'
: 'Delete this staging certificate? It will be regenerated on next request.'
if (confirm(message)) {
deleteMutation.mutate(cert.id!)
}
}}
className="text-red-400 hover:text-red-300 transition-colors"
title={cert.provider === 'custom' ? 'Delete Certificate' : 'Delete Staging Certificate'}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</>
)
}
function StatusBadge({ status }: { status: string }) {
const styles = {
valid: 'bg-green-900/30 text-green-400 border-green-800',
expiring: 'bg-yellow-900/30 text-yellow-400 border-yellow-800',
expired: 'bg-red-900/30 text-red-400 border-red-800',
untrusted: 'bg-orange-900/30 text-orange-400 border-orange-800',
}
const labels = {
valid: 'Valid',
expiring: 'Expiring Soon',
expired: 'Expired',
untrusted: 'Untrusted (Staging)',
}
const style = styles[status as keyof typeof styles] || styles.valid
const label = labels[status as keyof typeof labels] || status
return (
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${style}`}>
{label}
</span>
)
}