feat: implement certificate upload and deletion functionality, enhance certificate management in the API and frontend
This commit is contained in:
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ProxyHost {
|
||||
locations: Location[];
|
||||
advanced_config?: string;
|
||||
enabled: boolean;
|
||||
certificate_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user