feat: implement certificate upload and deletion functionality, enhance certificate management in the API and frontend

This commit is contained in:
Wikid82
2025-11-22 23:05:23 -05:00
parent 155bedcf66
commit ce89c63afc
14 changed files with 618 additions and 66 deletions
+21
View File
@@ -1,13 +1,34 @@
import client from './client'
export interface Certificate {
id?: number
name?: string
domain: string
issuer: string
expires_at: string
status: 'valid' | 'expiring' | 'expired'
provider: string
}
export async function getCertificates(): Promise<Certificate[]> {
const response = await client.get<Certificate[]>('/certificates')
return response.data
}
export async function uploadCertificate(name: string, certFile: File, keyFile: File): Promise<Certificate> {
const formData = new FormData()
formData.append('name', name)
formData.append('certificate_file', certFile)
formData.append('key_file', keyFile)
const response = await client.post<Certificate>('/certificates', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
}
export async function deleteCertificate(id: number): Promise<void> {
await client.delete(`/certificates/${id}`)
}
+1
View File
@@ -23,6 +23,7 @@ export interface ProxyHost {
locations: Location[];
advanced_config?: string;
enabled: boolean;
certificate_id?: number | null;
created_at: string;
updated_at: string;
}
+36 -2
View File
@@ -1,8 +1,24 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import { useCertificates } from '../hooks/useCertificates'
import { deleteCertificate } from '../api/certificates'
import { LoadingSpinner } from './LoadingStates'
import { toast } from '../utils/toast'
export default function CertificateList() {
const { certificates, isLoading, error } = useCertificates()
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: deleteCertificate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
toast.success('Certificate deleted')
},
onError: (error: any) => {
toast.error(`Failed to delete certificate: ${error.message}`)
},
})
if (isLoading) return <LoadingSpinner />
if (error) return <div className="text-red-500">Failed to load certificates</div>
@@ -13,22 +29,25 @@ export default function CertificateList() {
<table className="w-full text-left text-sm text-gray-400">
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
<tr>
<th className="px-6 py-3">Name</th>
<th className="px-6 py-3">Domain</th>
<th className="px-6 py-3">Issuer</th>
<th className="px-6 py-3">Expires</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={4} className="px-6 py-8 text-center text-gray-500">
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
No certificates found.
</td>
</tr>
) : (
certificates.map((cert) => (
<tr key={cert.domain} className="hover:bg-gray-800/50 transition-colors">
<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">{cert.issuer}</td>
<td className="px-6 py-4">
@@ -37,6 +56,21 @@ export default function CertificateList() {
<td className="px-6 py-4">
<StatusBadge status={cert.status} />
</td>
<td className="px-6 py-4">
{cert.provider === 'custom' && cert.id && (
<button
onClick={() => {
if (confirm('Are you sure you want to delete this certificate?')) {
deleteMutation.mutate(cert.id!)
}
}}
className="text-red-400 hover:text-red-300 transition-colors"
title="Delete Certificate"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))
)}
+22
View File
@@ -4,6 +4,7 @@ import type { ProxyHost } from '../api/proxyHosts'
import { testProxyHostConnection } from '../api/proxyHosts'
import { useRemoteServers } from '../hooks/useRemoteServers'
import { useDomains } from '../hooks/useDomains'
import { useCertificates } from '../hooks/useCertificates'
import { useDocker } from '../hooks/useDocker'
import { parse } from 'tldts'
@@ -27,10 +28,12 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
websocket_support: host?.websocket_support ?? true,
advanced_config: host?.advanced_config || '',
enabled: host?.enabled ?? true,
certificate_id: host?.certificate_id,
})
const { servers: remoteServers } = useRemoteServers()
const { domains, createDomain } = useDomains()
const { certificates } = useCertificates()
const [connectionSource, setConnectionSource] = useState<'local' | 'custom' | string>('custom')
const [selectedDomain, setSelectedDomain] = useState('')
const [selectedContainerId, setSelectedContainerId] = useState<string>('')
@@ -355,6 +358,25 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
</div>
</div>
{/* SSL Certificate Selection */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
SSL Certificate
</label>
<select
value={formData.certificate_id || 0}
onChange={e => setFormData({ ...formData, certificate_id: parseInt(e.target.value) || null })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={0}>Request a new SSL Certificate</option>
{certificates.filter(c => c.provider === 'custom').map(cert => (
<option key={cert.id} value={cert.id}>
{cert.name} ({cert.domain})
</option>
))}
</select>
</div>
{/* SSL & Security Options */}
<div className="space-y-3">
<label className="flex items-center gap-3">
+95 -1
View File
@@ -1,18 +1,112 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, X } from 'lucide-react'
import CertificateList from '../components/CertificateList'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { uploadCertificate } from '../api/certificates'
import { toast } from '../utils/toast'
export default function Certificates() {
const [isModalOpen, setIsModalOpen] = useState(false)
const [name, setName] = useState('')
const [certFile, setCertFile] = useState<File | null>(null)
const [keyFile, setKeyFile] = useState<File | null>(null)
const queryClient = useQueryClient()
const uploadMutation = useMutation({
mutationFn: async () => {
if (!certFile || !keyFile) throw new Error('Files required')
await uploadCertificate(name, certFile, keyFile)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
setIsModalOpen(false)
setName('')
setCertFile(null)
setKeyFile(null)
toast.success('Certificate uploaded successfully')
},
onError: (error: any) => {
toast.error(`Failed to upload certificate: ${error.message}`)
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
uploadMutation.mutate()
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-white mb-2">Certificates</h1>
<p className="text-gray-400">
View and manage SSL certificates automatically acquired by Caddy.
View and manage SSL certificates.
</p>
</div>
<Button onClick={() => setIsModalOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Add Certificate
</Button>
</div>
<CertificateList />
{isModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white">Upload Certificate</h2>
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Friendly Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. My Custom Cert"
required
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Certificate (PEM)
</label>
<input
type="file"
accept=".pem,.crt,.cer"
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Private Key (PEM)
</label>
<input
type="file"
accept=".pem,.key"
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700"
required
/>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>
Cancel
</Button>
<Button type="submit" isLoading={uploadMutation.isPending}>
Upload
</Button>
</div>
</form>
</div>
</div>
)}
</div>
)
}