feat: add certificate export and upload dialogs
- Implemented CertificateExportDialog for exporting certificates in various formats (PEM, PFX, DER) with options to include private keys and set passwords. - Created CertificateUploadDialog for uploading certificates, including validation and support for multiple file types (certificates, private keys, chain files). - Updated DeleteCertificateDialog to use 'domains' instead of 'domain' for consistency. - Refactored BulkDeleteCertificateDialog and DeleteCertificateDialog tests to accommodate changes in certificate structure. - Added FileDropZone component for improved file upload experience. - Enhanced translation files with new keys for certificate management features. - Updated Certificates page to utilize the new CertificateUploadDialog and clean up the upload logic. - Adjusted Dashboard and ProxyHosts pages to reflect changes in certificate data structure.
This commit is contained in:
@@ -17,12 +17,14 @@ describe('certificates API', () => {
|
||||
});
|
||||
|
||||
const mockCert: Certificate = {
|
||||
id: 1,
|
||||
domain: 'example.com',
|
||||
uuid: 'abc-123',
|
||||
domains: 'example.com',
|
||||
issuer: 'Let\'s Encrypt',
|
||||
expires_at: '2023-01-01',
|
||||
status: 'valid',
|
||||
provider: 'letsencrypt',
|
||||
has_key: true,
|
||||
in_use: false,
|
||||
};
|
||||
|
||||
it('getCertificates calls client.get', async () => {
|
||||
@@ -47,7 +49,7 @@ describe('certificates API', () => {
|
||||
|
||||
it('deleteCertificate calls client.delete', async () => {
|
||||
vi.mocked(client.delete).mockResolvedValue({ data: {} });
|
||||
await deleteCertificate(1);
|
||||
expect(client.delete).toHaveBeenCalledWith('/certificates/1');
|
||||
await deleteCertificate('abc-123');
|
||||
expect(client.delete).toHaveBeenCalledWith('/certificates/abc-123');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,53 +1,123 @@
|
||||
import client from './client'
|
||||
|
||||
/** Represents an SSL/TLS certificate. */
|
||||
export interface Certificate {
|
||||
id?: number
|
||||
uuid: string
|
||||
name?: string
|
||||
domain: string
|
||||
common_name?: string
|
||||
domains: string
|
||||
issuer: string
|
||||
issuer_org?: string
|
||||
fingerprint?: string
|
||||
serial_number?: string
|
||||
key_type?: string
|
||||
expires_at: string
|
||||
not_before?: string
|
||||
status: 'valid' | 'expiring' | 'expired' | 'untrusted'
|
||||
provider: string
|
||||
chain_depth?: number
|
||||
has_key: boolean
|
||||
in_use: boolean
|
||||
/** @deprecated Use uuid instead */
|
||||
id?: number
|
||||
}
|
||||
|
||||
export interface AssignedHost {
|
||||
uuid: string
|
||||
name: string
|
||||
domain_names: string
|
||||
}
|
||||
|
||||
export interface ChainEntry {
|
||||
subject: string
|
||||
issuer: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface CertificateDetail extends Certificate {
|
||||
assigned_hosts: AssignedHost[]
|
||||
chain: ChainEntry[]
|
||||
auto_renew: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
common_name: string
|
||||
domains: string[]
|
||||
issuer_org: string
|
||||
expires_at: string
|
||||
key_match: boolean
|
||||
chain_valid: boolean
|
||||
chain_depth: number
|
||||
warnings: string[]
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all SSL certificates.
|
||||
* @returns Promise resolving to array of Certificate objects
|
||||
* @throws {AxiosError} If the request fails
|
||||
*/
|
||||
export async function getCertificates(): Promise<Certificate[]> {
|
||||
const response = await client.get<Certificate[]>('/certificates')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a new SSL certificate with its private key.
|
||||
* @param name - Display name for the certificate
|
||||
* @param certFile - The certificate file (PEM format)
|
||||
* @param keyFile - The private key file (PEM format)
|
||||
* @returns Promise resolving to the created Certificate
|
||||
* @throws {AxiosError} If upload fails or certificate is invalid
|
||||
*/
|
||||
export async function uploadCertificate(name: string, certFile: File, keyFile: File): Promise<Certificate> {
|
||||
export async function getCertificateDetail(uuid: string): Promise<CertificateDetail> {
|
||||
const response = await client.get<CertificateDetail>(`/certificates/${uuid}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function uploadCertificate(
|
||||
name: string,
|
||||
certFile: File,
|
||||
keyFile?: File,
|
||||
chainFile?: File,
|
||||
): Promise<Certificate> {
|
||||
const formData = new FormData()
|
||||
formData.append('name', name)
|
||||
formData.append('certificate_file', certFile)
|
||||
formData.append('key_file', keyFile)
|
||||
if (keyFile) formData.append('key_file', keyFile)
|
||||
if (chainFile) formData.append('chain_file', chainFile)
|
||||
|
||||
const response = await client.post<Certificate>('/certificates', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an SSL certificate.
|
||||
* @param id - The ID of the certificate to delete
|
||||
* @throws {AxiosError} If deletion fails or certificate not found
|
||||
*/
|
||||
export async function deleteCertificate(id: number): Promise<void> {
|
||||
await client.delete(`/certificates/${id}`)
|
||||
export async function updateCertificate(uuid: string, name: string): Promise<Certificate> {
|
||||
const response = await client.put<Certificate>(`/certificates/${uuid}`, { name })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deleteCertificate(uuid: string): Promise<void> {
|
||||
await client.delete(`/certificates/${uuid}`)
|
||||
}
|
||||
|
||||
export async function exportCertificate(
|
||||
uuid: string,
|
||||
format: string,
|
||||
includeKey: boolean,
|
||||
password?: string,
|
||||
pfxPassword?: string,
|
||||
): Promise<Blob> {
|
||||
const response = await client.post(
|
||||
`/certificates/${uuid}/export`,
|
||||
{ format, include_key: includeKey, password, pfx_password: pfxPassword },
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
return response.data as Blob
|
||||
}
|
||||
|
||||
export async function validateCertificate(
|
||||
certFile: File,
|
||||
keyFile?: File,
|
||||
chainFile?: File,
|
||||
): Promise<ValidationResult> {
|
||||
const formData = new FormData()
|
||||
formData.append('certificate_file', certFile)
|
||||
if (keyFile) formData.append('key_file', keyFile)
|
||||
if (chainFile) formData.append('chain_file', chainFile)
|
||||
|
||||
const response = await client.post<ValidationResult>('/certificates/validate', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
72
frontend/src/components/CertificateChainViewer.tsx
Normal file
72
frontend/src/components/CertificateChainViewer.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Link2, ShieldCheck } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { ChainEntry } from '../api/certificates'
|
||||
|
||||
interface CertificateChainViewerProps {
|
||||
chain: ChainEntry[]
|
||||
}
|
||||
|
||||
function getChainLabel(index: number, total: number, t: (key: string) => string): string {
|
||||
if (index === 0) return t('certificates.chainLeaf')
|
||||
if (index === total - 1 && total > 1) return t('certificates.chainRoot')
|
||||
return t('certificates.chainIntermediate')
|
||||
}
|
||||
|
||||
export default function CertificateChainViewer({ chain }: CertificateChainViewerProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!chain || chain.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-content-muted italic">{t('certificates.noChainData')}</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-0"
|
||||
role="list"
|
||||
aria-label={t('certificates.certificateChain')}
|
||||
>
|
||||
{chain.map((entry, index) => {
|
||||
const label = getChainLabel(index, chain.length, t)
|
||||
const isLast = index === chain.length - 1
|
||||
|
||||
return (
|
||||
<div key={index} role="listitem">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-gray-700 bg-surface-muted">
|
||||
{index === 0 ? (
|
||||
<ShieldCheck className="h-4 w-4 text-brand-400" aria-hidden="true" />
|
||||
) : (
|
||||
<Link2 className="h-4 w-4 text-content-muted" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="w-px h-6 bg-gray-700" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-content-muted">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-content-primary truncate" title={entry.subject}>
|
||||
{entry.subject}
|
||||
</p>
|
||||
<p className="text-xs text-content-muted truncate" title={entry.issuer}>
|
||||
{t('certificates.issuerOrg')}: {entry.issuer}
|
||||
</p>
|
||||
<p className="text-xs text-content-muted">
|
||||
{t('certificates.expiresAt')}: {new Date(entry.expires_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,28 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { Download, Eye, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import BulkDeleteCertificateDialog from './dialogs/BulkDeleteCertificateDialog'
|
||||
import CertificateDetailDialog from './dialogs/CertificateDetailDialog'
|
||||
import CertificateExportDialog from './dialogs/CertificateExportDialog'
|
||||
import DeleteCertificateDialog from './dialogs/DeleteCertificateDialog'
|
||||
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
|
||||
import { Button } from './ui/Button'
|
||||
import { Checkbox } from './ui/Checkbox'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/Tooltip'
|
||||
import { deleteCertificate, type Certificate } from '../api/certificates'
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { type Certificate } from '../api/certificates'
|
||||
import { useCertificates, useDeleteCertificate, useBulkDeleteCertificates } from '../hooks/useCertificates'
|
||||
import { toast } from '../utils/toast'
|
||||
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
|
||||
type SortColumn = 'name' | 'expires'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export function isInUse(cert: Certificate, hosts: ProxyHost[]): boolean {
|
||||
if (!cert.id) return false
|
||||
return hosts.some(h => (h.certificate_id ?? h.certificate?.id) === cert.id)
|
||||
export function isInUse(cert: Certificate): boolean {
|
||||
return cert.in_use
|
||||
}
|
||||
|
||||
export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean {
|
||||
if (!cert.id) return false
|
||||
if (isInUse(cert, hosts)) return false
|
||||
export function isDeletable(cert: Certificate): boolean {
|
||||
if (cert.in_use) return false
|
||||
return (
|
||||
cert.provider === 'custom' ||
|
||||
cert.provider === 'letsencrypt-staging' ||
|
||||
@@ -35,65 +31,48 @@ export function isDeletable(cert: Certificate, hosts: ProxyHost[]): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function daysUntilExpiry(expiresAt: string): number {
|
||||
const now = new Date()
|
||||
const expiry = new Date(expiresAt)
|
||||
return Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export default function CertificateList() {
|
||||
const { certificates, isLoading, error } = useCertificates()
|
||||
const { hosts } = useProxyHosts()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation()
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
const [certToDelete, setCertToDelete] = useState<Certificate | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [certToView, setCertToView] = useState<Certificate | null>(null)
|
||||
const [certToExport, setCertToExport] = useState<Certificate | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false)
|
||||
|
||||
const deleteMutation = useDeleteCertificate()
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(prev => {
|
||||
const validIds = new Set(certificates.map(c => c.id).filter((id): id is number => id != null))
|
||||
const validIds = new Set(certificates.map(c => c.uuid).filter(Boolean))
|
||||
const reconciled = new Set([...prev].filter(id => validIds.has(id)))
|
||||
if (reconciled.size === prev.size) return prev
|
||||
return reconciled
|
||||
})
|
||||
}, [certificates])
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await deleteCertificate(id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
|
||||
toast.success(t('certificates.deleteSuccess'))
|
||||
setCertToDelete(null)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`${t('certificates.deleteFailed')}: ${error.message}`)
|
||||
setCertToDelete(null)
|
||||
},
|
||||
})
|
||||
const handleDelete = (cert: Certificate) => {
|
||||
deleteMutation.mutate(cert.uuid, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('certificates.deleteSuccess'))
|
||||
setCertToDelete(null)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`${t('certificates.deleteFailed')}: ${error.message}`)
|
||||
setCertToDelete(null)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const results = await Promise.allSettled(ids.map(id => deleteCertificate(id)))
|
||||
const failed = results.filter(r => r.status === 'rejected').length
|
||||
const succeeded = results.filter(r => r.status === 'fulfilled').length
|
||||
return { succeeded, failed }
|
||||
},
|
||||
onSuccess: ({ succeeded, failed }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteDialog(false)
|
||||
if (failed > 0) {
|
||||
toast.error(t('certificates.bulkDeletePartial', { deleted: succeeded, failed }))
|
||||
} else {
|
||||
toast.success(t('certificates.bulkDeleteSuccess', { count: succeeded }))
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('certificates.bulkDeleteFailed'))
|
||||
setShowBulkDeleteDialog(false)
|
||||
},
|
||||
})
|
||||
const bulkDeleteMutation = useBulkDeleteCertificates()
|
||||
|
||||
const sortedCertificates = useMemo(() => {
|
||||
return [...certificates].sort((a, b) => {
|
||||
@@ -101,8 +80,8 @@ export default function CertificateList() {
|
||||
|
||||
switch (sortColumn) {
|
||||
case 'name': {
|
||||
const aName = (a.name || a.domain || '').toLowerCase()
|
||||
const bName = (b.name || b.domain || '').toLowerCase()
|
||||
const aName = (a.name || a.domains || '').toLowerCase()
|
||||
const bName = (b.name || b.domains || '').toLowerCase()
|
||||
comparison = aName.localeCompare(bName)
|
||||
break
|
||||
}
|
||||
@@ -118,15 +97,15 @@ export default function CertificateList() {
|
||||
})
|
||||
}, [certificates, sortColumn, sortDirection])
|
||||
|
||||
const selectableCertIds = useMemo<Set<number>>(() => {
|
||||
const ids = new Set<number>()
|
||||
const selectableCertIds = useMemo<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const cert of sortedCertificates) {
|
||||
if (isDeletable(cert, hosts) && cert.id) {
|
||||
ids.add(cert.id)
|
||||
if (isDeletable(cert) && cert.uuid) {
|
||||
ids.add(cert.uuid)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [sortedCertificates, hosts])
|
||||
}, [sortedCertificates])
|
||||
|
||||
const allSelectableSelected =
|
||||
selectableCertIds.size > 0 && selectedIds.size === selectableCertIds.size
|
||||
@@ -141,12 +120,12 @@ export default function CertificateList() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectRow = (id: number) => {
|
||||
const handleSelectRow = (uuid: string) => {
|
||||
const next = new Set(selectedIds)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
if (next.has(uuid)) {
|
||||
next.delete(uuid)
|
||||
} else {
|
||||
next.add(id)
|
||||
next.add(uuid)
|
||||
}
|
||||
setSelectedIds(next)
|
||||
}
|
||||
@@ -243,18 +222,19 @@ export default function CertificateList() {
|
||||
</tr>
|
||||
) : (
|
||||
sortedCertificates.map((cert) => {
|
||||
const inUse = isInUse(cert, hosts)
|
||||
const deletable = isDeletable(cert, hosts)
|
||||
const inUse = isInUse(cert)
|
||||
const deletable = isDeletable(cert)
|
||||
const isInUseDeletableCategory = inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired' || cert.status === 'expiring')
|
||||
const days = daysUntilExpiry(cert.expires_at)
|
||||
|
||||
return (
|
||||
<tr key={cert.id || cert.domain} className="hover:bg-gray-800/50 transition-colors">
|
||||
<tr key={cert.uuid} className="hover:bg-gray-800/50 transition-colors">
|
||||
{deletable && !inUse ? (
|
||||
<td className="w-12 px-4 py-4">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(cert.id!)}
|
||||
onCheckedChange={() => handleSelectRow(cert.id!)}
|
||||
aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })}
|
||||
checked={selectedIds.has(cert.uuid)}
|
||||
onCheckedChange={() => handleSelectRow(cert.uuid)}
|
||||
aria-label={t('certificates.selectCert', { name: cert.name || cert.domains })}
|
||||
/>
|
||||
</td>
|
||||
) : isInUseDeletableCategory ? (
|
||||
@@ -267,7 +247,7 @@ export default function CertificateList() {
|
||||
checked={false}
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
aria-label={t('certificates.selectCert', { name: cert.name || cert.domain })}
|
||||
aria-label={t('certificates.selectCert', { name: cert.name || cert.domains })}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -279,7 +259,7 @@ export default function CertificateList() {
|
||||
<td className="w-12 px-4 py-4" aria-hidden="true" />
|
||||
)}
|
||||
<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 font-medium text-white">{cert.domains}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{cert.issuer}</span>
|
||||
@@ -291,49 +271,80 @@ export default function CertificateList() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{new Date(cert.expires_at).toLocaleDateString()}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={days <= 0 ? 'text-red-400' : days <= 30 ? 'text-yellow-400' : ''}>
|
||||
{new Date(cert.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{days > 0
|
||||
? t('certificates.expiresInDays', { days })
|
||||
: t('certificates.expiredAgo', { days: Math.abs(days) })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<StatusBadge status={cert.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{(() => {
|
||||
if (cert.id && inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-label={t('certificates.deleteTitle')}
|
||||
className="text-red-400/40 cursor-not-allowed transition-colors"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('certificates.deleteInUse')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCertToView(cert)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label={t('certificates.viewDetails')}
|
||||
data-testid={`view-cert-${cert.uuid}`}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCertToExport(cert)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label={t('certificates.export')}
|
||||
data-testid={`export-cert-${cert.uuid}`}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
{(() => {
|
||||
if (inUse && (cert.provider === 'custom' || cert.provider === 'letsencrypt-staging' || cert.status === 'expired')) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-label={t('certificates.deleteTitle')}
|
||||
className="text-red-400/40 cursor-not-allowed transition-colors"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('certificates.deleteInUse')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (deletable) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setCertToDelete(cert)}
|
||||
className="text-red-400 hover:text-red-300 transition-colors"
|
||||
aria-label={t('certificates.deleteTitle')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (deletable) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setCertToDelete(cert)}
|
||||
className="text-red-400 hover:text-red-300 transition-colors"
|
||||
aria-label={t('certificates.deleteTitle')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})()}
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -347,20 +358,44 @@ export default function CertificateList() {
|
||||
certificate={certToDelete}
|
||||
open={certToDelete !== null}
|
||||
onConfirm={() => {
|
||||
if (certToDelete?.id) {
|
||||
deleteMutation.mutate(certToDelete.id)
|
||||
if (certToDelete?.uuid) {
|
||||
handleDelete(certToDelete)
|
||||
}
|
||||
}}
|
||||
onCancel={() => setCertToDelete(null)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
<BulkDeleteCertificateDialog
|
||||
certificates={sortedCertificates.filter(c => c.id && selectedIds.has(c.id))}
|
||||
certificates={sortedCertificates.filter(c => selectedIds.has(c.uuid))}
|
||||
open={showBulkDeleteDialog}
|
||||
onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds))}
|
||||
onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds), {
|
||||
onSuccess: ({ succeeded, failed }) => {
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteDialog(false)
|
||||
if (failed > 0) {
|
||||
toast.error(t('certificates.bulkDeletePartial', { deleted: succeeded, failed }))
|
||||
} else {
|
||||
toast.success(t('certificates.bulkDeleteSuccess', { count: succeeded }))
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('certificates.bulkDeleteFailed'))
|
||||
setShowBulkDeleteDialog(false)
|
||||
},
|
||||
})}
|
||||
onCancel={() => setShowBulkDeleteDialog(false)}
|
||||
isDeleting={bulkDeleteMutation.isPending}
|
||||
/>
|
||||
<CertificateDetailDialog
|
||||
certificate={certToView}
|
||||
open={certToView !== null}
|
||||
onOpenChange={(open) => { if (!open) setCertToView(null) }}
|
||||
/>
|
||||
<CertificateExportDialog
|
||||
certificate={certToExport}
|
||||
open={certToExport !== null}
|
||||
onOpenChange={(open) => { if (!open) setCertToExport(null) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ export default function CertificateStatusCard({ certificates, hosts, isLoading }
|
||||
const domains = new Set<string>()
|
||||
for (const cert of certificates) {
|
||||
// Handle missing or undefined domain field
|
||||
if (!cert.domain) continue
|
||||
// Certificate domain field can be comma-separated
|
||||
for (const d of cert.domain.split(',')) {
|
||||
if (!cert.domains) continue
|
||||
// Certificate domains field can be comma-separated
|
||||
for (const d of cert.domains.split(',')) {
|
||||
const trimmed = d.trim().toLowerCase()
|
||||
if (trimmed) domains.add(trimmed)
|
||||
}
|
||||
|
||||
107
frontend/src/components/CertificateValidationPreview.tsx
Normal file
107
frontend/src/components/CertificateValidationPreview.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { AlertTriangle, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { ValidationResult } from '../api/certificates'
|
||||
|
||||
interface CertificateValidationPreviewProps {
|
||||
result: ValidationResult
|
||||
}
|
||||
|
||||
export default function CertificateValidationPreview({
|
||||
result,
|
||||
}: CertificateValidationPreviewProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-gray-700 bg-surface-muted/50 p-4 space-y-3"
|
||||
data-testid="certificate-validation-preview"
|
||||
role="region"
|
||||
aria-label={t('certificates.validationPreview')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.valid ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-400" aria-hidden="true" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
)}
|
||||
<span className="font-medium text-content-primary">
|
||||
{result.valid
|
||||
? t('certificates.validCertificate')
|
||||
: t('certificates.invalidCertificate')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
<dt className="text-content-muted">{t('certificates.commonName')}</dt>
|
||||
<dd className="text-content-primary">{result.common_name || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.domains')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{result.domains?.length ? result.domains.join(', ') : '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.issuerOrg')}</dt>
|
||||
<dd className="text-content-primary">{result.issuer_org || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.expiresAt')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{result.expires_at ? new Date(result.expires_at).toLocaleDateString() : '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.keyMatch')}</dt>
|
||||
<dd>
|
||||
{result.key_match ? (
|
||||
<span className="text-green-400">Yes</span>
|
||||
) : (
|
||||
<span className="text-yellow-400">No key provided</span>
|
||||
)}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.chainValid')}</dt>
|
||||
<dd>
|
||||
{result.chain_valid ? (
|
||||
<span className="text-green-400">Yes</span>
|
||||
) : (
|
||||
<span className="text-yellow-400">Not verified</span>
|
||||
)}
|
||||
</dd>
|
||||
|
||||
{result.chain_depth > 0 && (
|
||||
<>
|
||||
<dt className="text-content-muted">{t('certificates.chainDepth')}</dt>
|
||||
<dd className="text-content-primary">{result.chain_depth}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{result.warnings.length > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-yellow-900/50 bg-yellow-900/10 p-3">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-400 mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-yellow-400">{t('certificates.warnings')}</p>
|
||||
<ul className="list-disc list-inside text-sm text-yellow-300/80 space-y-0.5">
|
||||
{result.warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-red-900/50 bg-red-900/10 p-3">
|
||||
<XCircle className="h-4 w-4 text-red-400 mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-red-400">{t('certificates.errors')}</p>
|
||||
<ul className="list-disc list-inside text-sm text-red-300/80 space-y-0.5">
|
||||
{result.errors.map((e, i) => (
|
||||
<li key={i}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -917,8 +917,8 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
<SelectContent>
|
||||
<SelectItem value="0">Auto-manage with Let's Encrypt (recommended)</SelectItem>
|
||||
{certificates.map(cert => (
|
||||
<SelectItem key={cert.id || cert.domain} value={String(cert.id ?? 0)}>
|
||||
{(cert.name || cert.domain)}
|
||||
<SelectItem key={cert.id || cert.domains} value={String(cert.id ?? 0)}>
|
||||
{(cert.name || cert.domains)}
|
||||
{cert.provider ? ` (${cert.provider})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -3,16 +3,18 @@ import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { useCertificates } from '../../hooks/useCertificates'
|
||||
import { useProxyHosts } from '../../hooks/useProxyHosts'
|
||||
import { useCertificates, useDeleteCertificate, useBulkDeleteCertificates } from '../../hooks/useCertificates'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
import CertificateList, { isDeletable, isInUse } from '../CertificateList'
|
||||
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(),
|
||||
useCertificateDetail: vi.fn(() => ({ detail: null, isLoading: false })),
|
||||
useDeleteCertificate: vi.fn(),
|
||||
useBulkDeleteCertificates: vi.fn(),
|
||||
useExportCertificate: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
@@ -30,10 +32,6 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useProxyHosts', () => ({
|
||||
useProxyHosts: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}))
|
||||
@@ -43,14 +41,26 @@ function renderWithClient(ui: React.ReactNode) {
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
const makeCert = (overrides: Partial<Certificate> = {}): Certificate => ({
|
||||
uuid: 'cert-1',
|
||||
domains: 'example.com',
|
||||
issuer: 'Custom CA',
|
||||
expires_at: '2026-03-01T00:00:00Z',
|
||||
status: 'valid',
|
||||
provider: 'custom',
|
||||
has_key: true,
|
||||
in_use: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createCertificatesValue = (overrides: Partial<ReturnType<typeof useCertificates>> = {}) => {
|
||||
const certificates: Certificate[] = [
|
||||
{ id: 1, name: 'CustomCert', domain: 'example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'expired', provider: 'custom' },
|
||||
{ id: 2, name: 'LE Staging', domain: 'staging.example.com', issuer: "Let's Encrypt Staging", expires_at: '2026-04-01T00:00:00Z', status: 'untrusted', provider: 'letsencrypt-staging' },
|
||||
{ id: 3, name: 'ActiveCert', domain: 'active.example.com', issuer: 'Custom CA', expires_at: '2026-02-01T00:00:00Z', status: 'valid', provider: 'custom' },
|
||||
{ id: 4, name: 'UnusedValidCert', domain: 'unused.example.com', issuer: 'Custom CA', expires_at: '2026-05-01T00:00:00Z', status: 'valid', provider: 'custom' },
|
||||
{ id: 5, name: 'ExpiredLE', domain: 'expired-le.example.com', issuer: "Let's Encrypt", expires_at: '2025-01-01T00:00:00Z', status: 'expired', provider: 'letsencrypt' },
|
||||
{ id: 6, name: 'ValidLE', domain: 'valid-le.example.com', issuer: "Let's Encrypt", expires_at: '2026-12-01T00:00:00Z', status: 'valid', provider: 'letsencrypt' },
|
||||
makeCert({ uuid: 'cert-1', name: 'CustomCert', domains: 'example.com', status: 'expired', in_use: false }),
|
||||
makeCert({ uuid: 'cert-2', name: 'LE Staging', domains: 'staging.example.com', issuer: "Let's Encrypt Staging", status: 'untrusted', provider: 'letsencrypt-staging', in_use: false }),
|
||||
makeCert({ uuid: 'cert-3', name: 'ActiveCert', domains: 'active.example.com', status: 'valid', in_use: true }),
|
||||
makeCert({ uuid: 'cert-4', name: 'UnusedValidCert', domains: 'unused.example.com', status: 'valid', in_use: false }),
|
||||
makeCert({ uuid: 'cert-5', name: 'ExpiredLE', domains: 'expired-le.example.com', issuer: "Let's Encrypt", expires_at: '2025-01-01T00:00:00Z', status: 'expired', provider: 'letsencrypt', in_use: false }),
|
||||
makeCert({ uuid: 'cert-6', name: 'ValidLE', domains: 'valid-le.example.com', issuer: "Let's Encrypt", expires_at: '2026-12-01T00:00:00Z', status: 'valid', provider: 'letsencrypt', in_use: false }),
|
||||
]
|
||||
|
||||
return {
|
||||
@@ -62,126 +72,68 @@ const createCertificatesValue = (overrides: Partial<ReturnType<typeof useCertifi
|
||||
}
|
||||
}
|
||||
|
||||
const createProxyHost = (overrides: Partial<ProxyHost> = {}): ProxyHost => ({
|
||||
uuid: 'h1',
|
||||
name: 'Host1',
|
||||
domain_names: 'host1.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '127.0.0.1',
|
||||
forward_port: 80,
|
||||
ssl_forced: false,
|
||||
http2_support: true,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
websocket_support: false,
|
||||
application: 'none',
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2026-02-01T00:00:00Z',
|
||||
updated_at: '2026-02-01T00:00:00Z',
|
||||
certificate_id: 3,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createProxyHostsValue = (overrides: Partial<ReturnType<typeof useProxyHosts>> = {}): ReturnType<typeof useProxyHosts> => ({
|
||||
hosts: [
|
||||
createProxyHost(),
|
||||
],
|
||||
loading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
createHost: vi.fn(),
|
||||
updateHost: vi.fn(),
|
||||
deleteHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
bulkUpdateSecurityHeaders: vi.fn(),
|
||||
isCreating: false,
|
||||
isUpdating: false,
|
||||
isDeleting: false,
|
||||
isBulkUpdating: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const getRowNames = () =>
|
||||
screen
|
||||
.getAllByRole('row')
|
||||
.slice(1)
|
||||
.map(row => row.querySelectorAll('td')[1]?.textContent?.trim() ?? '')
|
||||
|
||||
let deleteMutateFn: ReturnType<typeof vi.fn>
|
||||
let bulkDeleteMutateFn: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
deleteMutateFn = vi.fn()
|
||||
bulkDeleteMutateFn = vi.fn()
|
||||
vi.mocked(useCertificates).mockReturnValue(createCertificatesValue())
|
||||
vi.mocked(useProxyHosts).mockReturnValue(createProxyHostsValue())
|
||||
vi.mocked(useDeleteCertificate).mockReturnValue({
|
||||
mutate: deleteMutateFn,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useDeleteCertificate>)
|
||||
vi.mocked(useBulkDeleteCertificates).mockReturnValue({
|
||||
mutate: bulkDeleteMutateFn,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useBulkDeleteCertificates>)
|
||||
})
|
||||
|
||||
describe('CertificateList', () => {
|
||||
describe('isDeletable', () => {
|
||||
const noHosts: ProxyHost[] = []
|
||||
const withHost = (certId: number): ProxyHost[] => [createProxyHost({ certificate_id: certId })]
|
||||
|
||||
it('returns true for custom cert not in use', () => {
|
||||
const cert: Certificate = { id: 1, name: 'C', domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
|
||||
expect(isDeletable(cert, noHosts)).toBe(true)
|
||||
expect(isDeletable(makeCert({ provider: 'custom', in_use: false }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for staging cert not in use', () => {
|
||||
const cert: Certificate = { id: 2, name: 'S', domain: 'd', issuer: 'X', expires_at: '', status: 'untrusted', provider: 'letsencrypt-staging' }
|
||||
expect(isDeletable(cert, noHosts)).toBe(true)
|
||||
expect(isDeletable(makeCert({ provider: 'letsencrypt-staging', in_use: false }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for expired LE cert not in use', () => {
|
||||
const cert: Certificate = { id: 3, name: 'E', domain: 'd', issuer: 'LE', expires_at: '', status: 'expired', provider: 'letsencrypt' }
|
||||
expect(isDeletable(cert, noHosts)).toBe(true)
|
||||
expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expired', in_use: false }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for valid LE cert not in use', () => {
|
||||
const cert: Certificate = { id: 4, name: 'V', domain: 'd', issuer: 'LE', expires_at: '', status: 'valid', provider: 'letsencrypt' }
|
||||
expect(isDeletable(cert, noHosts)).toBe(false)
|
||||
expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'valid', in_use: false }))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for cert in use', () => {
|
||||
const cert: Certificate = { id: 5, name: 'U', domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
|
||||
expect(isDeletable(cert, withHost(5))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for cert without id', () => {
|
||||
const cert: Certificate = { domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
|
||||
expect(isDeletable(cert, noHosts)).toBe(false)
|
||||
expect(isDeletable(makeCert({ provider: 'custom', in_use: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for expiring LE cert not in use', () => {
|
||||
const cert: Certificate = { id: 7, name: 'Exp', domain: 'd', issuer: 'LE', expires_at: '', status: 'expiring', provider: 'letsencrypt' }
|
||||
expect(isDeletable(cert, noHosts)).toBe(true)
|
||||
expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expiring', in_use: false }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for expiring LE cert that is in use', () => {
|
||||
const cert: Certificate = { id: 7, name: 'Exp', domain: 'd', issuer: 'LE', expires_at: '', status: 'expiring', provider: 'letsencrypt' }
|
||||
expect(isDeletable(cert, withHost(7))).toBe(false)
|
||||
expect(isDeletable(makeCert({ provider: 'letsencrypt', status: 'expiring', in_use: true }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInUse', () => {
|
||||
it('returns true when host references cert by certificate_id', () => {
|
||||
const cert: Certificate = { id: 10, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
|
||||
expect(isInUse(cert, [createProxyHost({ certificate_id: 10 })])).toBe(true)
|
||||
it('returns true when cert.in_use is true', () => {
|
||||
expect(isInUse(makeCert({ in_use: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when host references cert via certificate.id', () => {
|
||||
const cert: Certificate = { id: 10, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
|
||||
const host = createProxyHost({ certificate_id: undefined, certificate: { id: 10, uuid: 'u', name: 'c', provider: 'custom', domains: 'd', expires_at: '' } })
|
||||
expect(isInUse(cert, [host])).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no host references cert', () => {
|
||||
const cert: Certificate = { id: 99, domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
|
||||
expect(isInUse(cert, [createProxyHost({ certificate_id: 3 })])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when cert.id is undefined even if a host has certificate_id undefined', () => {
|
||||
const cert: Certificate = { domain: 'd', issuer: 'X', expires_at: '', status: 'valid', provider: 'custom' }
|
||||
const host = createProxyHost({ certificate_id: undefined })
|
||||
expect(isInUse(cert, [host])).toBe(false)
|
||||
it('returns false when cert.in_use is false', () => {
|
||||
expect(isInUse(makeCert({ in_use: false }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -215,7 +167,6 @@ describe('CertificateList', () => {
|
||||
})
|
||||
|
||||
it('opens dialog and deletes cert on confirm', async () => {
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
@@ -228,7 +179,7 @@ describe('CertificateList', () => {
|
||||
expect(within(dialog).getByText('certificates.deleteTitle')).toBeInTheDocument()
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
|
||||
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(1))
|
||||
await waitFor(() => expect(deleteMutateFn).toHaveBeenCalledWith('cert-1', expect.any(Object)))
|
||||
})
|
||||
|
||||
it('does not call createBackup on delete (server handles it)', async () => {
|
||||
@@ -257,23 +208,6 @@ describe('CertificateList', () => {
|
||||
expect(await screen.findByText('Failed to load certificates')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error toast when delete mutation fails', async () => {
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const { toast } = await import('../../utils/toast')
|
||||
vi.mocked(deleteCertificate).mockRejectedValueOnce(new Error('Network error'))
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
|
||||
await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.deleteFailed: Network error'))
|
||||
})
|
||||
|
||||
it('clicking disabled delete button for in-use cert does not open dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
@@ -299,7 +233,7 @@ describe('CertificateList', () => {
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('renders enabled checkboxes for deletable not-in-use certs (ids 1, 2, 4, 5)', async () => {
|
||||
it('renders enabled checkboxes for deletable not-in-use certs', async () => {
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
for (const name of ['CustomCert', 'LE Staging', 'UnusedValidCert', 'ExpiredLE']) {
|
||||
@@ -310,7 +244,7 @@ describe('CertificateList', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('renders disabled checkbox for in-use cert (id 3)', async () => {
|
||||
it('renders disabled checkbox for in-use cert', async () => {
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const activeRow = rows.find(r => r.textContent?.includes('ActiveCert'))!
|
||||
@@ -320,7 +254,7 @@ describe('CertificateList', () => {
|
||||
expect(rowCheckbox).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('renders no checkbox in valid production LE cert row (id 6)', async () => {
|
||||
it('renders no checkbox in valid production LE cert row', async () => {
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const validLeRow = rows.find(r => r.textContent?.includes('ValidLE'))!
|
||||
@@ -360,8 +294,7 @@ describe('CertificateList', () => {
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('confirming in the bulk dialog calls deleteCertificate for each selected ID', async () => {
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
it('confirming in the bulk dialog calls bulk delete for selected UUIDs', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
@@ -373,16 +306,17 @@ describe('CertificateList', () => {
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
|
||||
await waitFor(() => {
|
||||
expect(deleteCertificate).toHaveBeenCalledWith(1)
|
||||
expect(deleteCertificate).toHaveBeenCalledWith(2)
|
||||
expect(bulkDeleteMutateFn).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['cert-1', 'cert-2']),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows partial failure toast when some bulk deletes fail', async () => {
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const { toast } = await import('../../utils/toast')
|
||||
vi.mocked(deleteCertificate).mockImplementation(async (id: number) => {
|
||||
if (id === 2) throw new Error('network error')
|
||||
bulkDeleteMutateFn.mockImplementation((_uuids: string[], { onSuccess }: { onSuccess: (data: { succeeded: number; failed: number }) => void }) => {
|
||||
onSuccess({ succeeded: 1, failed: 1 })
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
@@ -410,8 +344,8 @@ describe('CertificateList', () => {
|
||||
|
||||
it('sorts certificates by name and expiry when headers are clicked', async () => {
|
||||
const certificates: Certificate[] = [
|
||||
{ id: 10, name: 'Zulu', domain: 'z.example.com', issuer: 'Custom CA', expires_at: '2026-03-01T00:00:00Z', status: 'valid', provider: 'custom' },
|
||||
{ id: 11, name: 'Alpha', domain: 'a.example.com', issuer: 'Custom CA', expires_at: '2026-01-01T00:00:00Z', status: 'valid', provider: 'custom' },
|
||||
makeCert({ uuid: 'cert-z', name: 'Zulu', domains: 'z.example.com', expires_at: '2026-03-01T00:00:00Z' }),
|
||||
makeCert({ uuid: 'cert-a', name: 'Alpha', domains: 'a.example.com', expires_at: '2026-01-01T00:00:00Z' }),
|
||||
]
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -8,13 +8,15 @@ import type { Certificate } from '../../api/certificates'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
|
||||
const mockCert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
name: 'Test Cert',
|
||||
domain: 'example.com',
|
||||
domains: 'example.com',
|
||||
issuer: "Let's Encrypt",
|
||||
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'valid',
|
||||
provider: 'letsencrypt',
|
||||
has_key: true,
|
||||
in_use: false,
|
||||
}
|
||||
|
||||
const mockHost: ProxyHost = {
|
||||
@@ -42,13 +44,15 @@ const mockHost: ProxyHost = {
|
||||
// Helper to create a certificate with a specific domain
|
||||
function mockCertWithDomain(domain: string, status: 'valid' | 'expiring' | 'expired' | 'untrusted' = 'valid'): Certificate {
|
||||
return {
|
||||
id: Math.floor(Math.random() * 10000),
|
||||
uuid: `cert-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: domain,
|
||||
domain: domain,
|
||||
domains: domain,
|
||||
issuer: "Let's Encrypt",
|
||||
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status,
|
||||
provider: 'letsencrypt',
|
||||
has_key: true,
|
||||
in_use: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +62,7 @@ function renderWithRouter(ui: React.ReactNode) {
|
||||
|
||||
describe('CertificateStatusCard', () => {
|
||||
it('shows total certificate count', () => {
|
||||
const certs: Certificate[] = [mockCert, { ...mockCert, id: 2 }, { ...mockCert, id: 3 }]
|
||||
const certs: Certificate[] = [mockCert, { ...mockCert, uuid: 'cert-2' }, { ...mockCert, uuid: 'cert-3' }]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
@@ -68,8 +72,8 @@ describe('CertificateStatusCard', () => {
|
||||
it('shows valid certificate count', () => {
|
||||
const certs: Certificate[] = [
|
||||
{ ...mockCert, status: 'valid' },
|
||||
{ ...mockCert, id: 2, status: 'valid' },
|
||||
{ ...mockCert, id: 3, status: 'expired' },
|
||||
{ ...mockCert, uuid: 'cert-2', status: 'valid' },
|
||||
{ ...mockCert, uuid: 'cert-3', status: 'expired' },
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
@@ -79,7 +83,7 @@ describe('CertificateStatusCard', () => {
|
||||
it('shows expiring count when certificates are expiring', () => {
|
||||
const certs: Certificate[] = [
|
||||
{ ...mockCert, status: 'expiring' },
|
||||
{ ...mockCert, id: 2, status: 'valid' },
|
||||
{ ...mockCert, uuid: 'cert-2', status: 'valid' },
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
@@ -96,7 +100,7 @@ describe('CertificateStatusCard', () => {
|
||||
it('shows staging count for untrusted certificates', () => {
|
||||
const certs: Certificate[] = [
|
||||
{ ...mockCert, status: 'untrusted' },
|
||||
{ ...mockCert, id: 2, status: 'untrusted' },
|
||||
{ ...mockCert, uuid: 'cert-2', status: 'untrusted' },
|
||||
]
|
||||
renderWithRouter(<CertificateStatusCard certificates={certs} hosts={[]} />)
|
||||
|
||||
@@ -206,7 +210,7 @@ describe('CertificateStatusCard - Domain Matching', () => {
|
||||
it('handles comma-separated certificate domains', () => {
|
||||
const certs: Certificate[] = [{
|
||||
...mockCertWithDomain('example.com'),
|
||||
domain: 'example.com, www.example.com'
|
||||
domains: 'example.com, www.example.com'
|
||||
}]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'www.example.com', ssl_forced: true, certificate_id: null, enabled: true }
|
||||
@@ -295,7 +299,7 @@ describe('CertificateStatusCard - Domain Matching', () => {
|
||||
it('handles whitespace in certificate domains', () => {
|
||||
const certs: Certificate[] = [{
|
||||
...mockCertWithDomain('example.com'),
|
||||
domain: ' example.com '
|
||||
domains: ' example.com '
|
||||
}]
|
||||
const hosts: ProxyHost[] = [
|
||||
{ ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true }
|
||||
|
||||
@@ -64,10 +64,10 @@ export default function BulkDeleteCertificateDialog({
|
||||
>
|
||||
{certificates.map((cert) => (
|
||||
<li
|
||||
key={cert.id ?? cert.domain}
|
||||
key={cert.uuid}
|
||||
className="flex items-center justify-between px-4 py-2"
|
||||
>
|
||||
<span className="text-sm text-white">{cert.name || cert.domain}</span>
|
||||
<span className="text-sm text-white">{cert.name || cert.domains}</span>
|
||||
<span className="text-xs text-gray-500">{providerLabel(cert, t)}</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AlertTriangle } from 'lucide-react'
|
||||
interface CertificateCleanupDialogProps {
|
||||
onConfirm: (deleteCerts: boolean) => void
|
||||
onCancel: () => void
|
||||
certificates: Array<{ id: number; name: string; domain: string }>
|
||||
certificates: Array<{ uuid: string; name: string; domain: string }>
|
||||
hostNames: string[]
|
||||
isBulk?: boolean
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export default function CertificateCleanupDialog({
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{certificates.map((cert) => (
|
||||
<li key={cert.id} className="text-xs text-gray-300 flex items-center gap-2">
|
||||
<li key={cert.uuid} className="text-xs text-gray-300 flex items-center gap-2">
|
||||
<span className="text-orange-400">→</span>
|
||||
<span className="font-medium">{cert.name || cert.domain}</span>
|
||||
<span className="text-gray-500">({cert.domain})</span>
|
||||
|
||||
143
frontend/src/components/dialogs/CertificateDetailDialog.tsx
Normal file
143
frontend/src/components/dialogs/CertificateDetailDialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import { useCertificateDetail } from '../../hooks/useCertificates'
|
||||
import CertificateChainViewer from '../CertificateChainViewer'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui'
|
||||
|
||||
interface CertificateDetailDialogProps {
|
||||
certificate: Certificate | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export default function CertificateDetailDialog({
|
||||
certificate,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CertificateDetailDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { detail, isLoading } = useCertificateDetail(
|
||||
open && certificate ? certificate.uuid : null,
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
data-testid="certificate-detail-dialog"
|
||||
className="max-w-lg max-h-[85vh] overflow-y-auto"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('certificates.detailTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<div className="space-y-6 py-2">
|
||||
<section>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-content-muted">{t('certificates.friendlyName')}</dt>
|
||||
<dd className="text-content-primary">{detail.name || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.commonName')}</dt>
|
||||
<dd className="text-content-primary">{detail.common_name || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.domains')}</dt>
|
||||
<dd className="text-content-primary">{detail.domains || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.issuerOrg')}</dt>
|
||||
<dd className="text-content-primary">{detail.issuer_org || detail.issuer || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.fingerprint')}</dt>
|
||||
<dd className="text-content-primary font-mono text-xs break-all">
|
||||
{detail.fingerprint || '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.serialNumber')}</dt>
|
||||
<dd className="text-content-primary font-mono text-xs break-all">
|
||||
{detail.serial_number || '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.keyType')}</dt>
|
||||
<dd className="text-content-primary">{detail.key_type || '-'}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.status')}</dt>
|
||||
<dd className="text-content-primary capitalize">{detail.status}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.provider')}</dt>
|
||||
<dd className="text-content-primary">{detail.provider}</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.notBefore')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{detail.not_before ? new Date(detail.not_before).toLocaleDateString() : '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.expiresAt')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{detail.expires_at ? new Date(detail.expires_at).toLocaleDateString() : '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.autoRenew')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{detail.auto_renew ? t('common.yes') : t('common.no')}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.createdAt')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{detail.created_at ? new Date(detail.created_at).toLocaleDateString() : '-'}
|
||||
</dd>
|
||||
|
||||
<dt className="text-content-muted">{t('certificates.updatedAt')}</dt>
|
||||
<dd className="text-content-primary">
|
||||
{detail.updated_at ? new Date(detail.updated_at).toLocaleDateString() : '-'}
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-content-primary mb-3">
|
||||
{t('certificates.assignedHosts')}
|
||||
</h3>
|
||||
{detail.assigned_hosts?.length > 0 ? (
|
||||
<ul className="space-y-1.5">
|
||||
{detail.assigned_hosts.map((host) => (
|
||||
<li
|
||||
key={host.uuid}
|
||||
className="flex items-center justify-between rounded-md border border-gray-700 bg-surface-muted/30 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-content-primary font-medium">{host.name}</span>
|
||||
<span className="text-content-muted text-xs">{host.domain_names}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-content-muted italic">
|
||||
{t('certificates.noAssignedHosts')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-content-primary mb-3">
|
||||
{t('certificates.certificateChain')}
|
||||
</h3>
|
||||
<CertificateChainViewer chain={detail.chain || []} />
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
187
frontend/src/components/dialogs/CertificateExportDialog.tsx
Normal file
187
frontend/src/components/dialogs/CertificateExportDialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Download } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { Certificate } from '../../api/certificates'
|
||||
import { useExportCertificate } from '../../hooks/useCertificates'
|
||||
import { toast } from '../../utils/toast'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Label,
|
||||
} from '../ui'
|
||||
|
||||
interface CertificateExportDialogProps {
|
||||
certificate: Certificate | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS = [
|
||||
{ value: 'pem', label: 'exportFormatPem' },
|
||||
{ value: 'pfx', label: 'exportFormatPfx' },
|
||||
{ value: 'der', label: 'exportFormatDer' },
|
||||
] as const
|
||||
|
||||
export default function CertificateExportDialog({
|
||||
certificate,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CertificateExportDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [format, setFormat] = useState('pem')
|
||||
const [includeKey, setIncludeKey] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [pfxPassword, setPfxPassword] = useState('')
|
||||
|
||||
const exportMutation = useExportCertificate()
|
||||
|
||||
function resetForm() {
|
||||
setFormat('pem')
|
||||
setIncludeKey(false)
|
||||
setPassword('')
|
||||
setPfxPassword('')
|
||||
}
|
||||
|
||||
function handleClose(nextOpen: boolean) {
|
||||
if (!nextOpen) resetForm()
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!certificate) return
|
||||
|
||||
exportMutation.mutate(
|
||||
{
|
||||
uuid: certificate.uuid,
|
||||
format,
|
||||
includeKey,
|
||||
password: includeKey ? password : undefined,
|
||||
pfxPassword: format === 'pfx' ? pfxPassword : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (blob) => {
|
||||
const ext = format === 'pfx' ? 'pfx' : format === 'der' ? 'der' : 'pem'
|
||||
const filename = `${certificate.name || 'certificate'}.${ext}`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
a.remove()
|
||||
toast.success(t('certificates.exportSuccess'))
|
||||
handleClose(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`${t('certificates.exportFailed')}: ${error.message}`)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent data-testid="certificate-export-dialog" className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Download className="inline h-5 w-5 mr-2" aria-hidden="true" />
|
||||
{t('certificates.exportTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label htmlFor="export-format">{t('certificates.exportFormat')}</Label>
|
||||
<div className="flex gap-2 mt-1.5" role="radiogroup" aria-label={t('certificates.exportFormat')}>
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={format === opt.value}
|
||||
onClick={() => setFormat(opt.value)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 ${
|
||||
format === opt.value
|
||||
? 'border-brand-500 bg-brand-500/20 text-brand-400'
|
||||
: 'border-gray-700 text-content-muted hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{t(`certificates.${opt.label}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{certificate?.has_key && (
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id="include-key"
|
||||
type="checkbox"
|
||||
checked={includeKey}
|
||||
onChange={(e) => setIncludeKey(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-700 bg-surface-muted text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="include-key" className="cursor-pointer">
|
||||
{t('certificates.includePrivateKey')}
|
||||
</Label>
|
||||
{includeKey && (
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
{t('certificates.includePrivateKeyWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{includeKey && (
|
||||
<Input
|
||||
id="export-password"
|
||||
label={t('certificates.exportPassword')}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
)}
|
||||
|
||||
{format === 'pfx' && (
|
||||
<Input
|
||||
id="pfx-password"
|
||||
label={t('certificates.exportPfxPassword')}
|
||||
type="password"
|
||||
value={pfxPassword}
|
||||
onChange={(e) => setPfxPassword(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => handleClose(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={exportMutation.isPending}
|
||||
data-testid="export-certificate-submit"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||
{t('certificates.exportButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
205
frontend/src/components/dialogs/CertificateUploadDialog.tsx
Normal file
205
frontend/src/components/dialogs/CertificateUploadDialog.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { ValidationResult } from '../../api/certificates'
|
||||
import CertificateValidationPreview from '../CertificateValidationPreview'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '../ui'
|
||||
import { FileDropZone } from '../ui/FileDropZone'
|
||||
|
||||
import { useUploadCertificate, useValidateCertificate } from '../../hooks/useCertificates'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
interface CertificateUploadDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function detectFormat(file: File | null): string | null {
|
||||
if (!file) return null
|
||||
const ext = file.name.toLowerCase().split('.').pop()
|
||||
if (ext === 'pfx' || ext === 'p12') return 'PFX/PKCS#12'
|
||||
if (ext === 'pem' || ext === 'crt' || ext === 'cer') return 'PEM'
|
||||
if (ext === 'der') return 'DER'
|
||||
if (ext === 'key') return 'KEY'
|
||||
return null
|
||||
}
|
||||
|
||||
export default function CertificateUploadDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CertificateUploadDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [certFile, setCertFile] = useState<File | null>(null)
|
||||
const [keyFile, setKeyFile] = useState<File | null>(null)
|
||||
const [chainFile, setChainFile] = useState<File | null>(null)
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null)
|
||||
|
||||
const uploadMutation = useUploadCertificate()
|
||||
const validateMutation = useValidateCertificate()
|
||||
|
||||
const certFormat = detectFormat(certFile)
|
||||
const isPfx = certFormat === 'PFX/PKCS#12'
|
||||
|
||||
function resetForm() {
|
||||
setName('')
|
||||
setCertFile(null)
|
||||
setKeyFile(null)
|
||||
setChainFile(null)
|
||||
setValidationResult(null)
|
||||
}
|
||||
|
||||
function handleClose(nextOpen: boolean) {
|
||||
if (!nextOpen) resetForm()
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
function handleValidate() {
|
||||
if (!certFile) return
|
||||
validateMutation.mutate(
|
||||
{ certFile, keyFile: keyFile ?? undefined, chainFile: chainFile ?? undefined },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setValidationResult(result)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!certFile) return
|
||||
|
||||
uploadMutation.mutate(
|
||||
{
|
||||
name,
|
||||
certFile,
|
||||
keyFile: keyFile ?? undefined,
|
||||
chainFile: chainFile ?? undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('certificates.uploadSuccess'))
|
||||
handleClose(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`${t('certificates.uploadFailed')}: ${error.message}`)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const canValidate = !!certFile && !validateMutation.isPending
|
||||
const canSubmit = !!certFile && !!name.trim()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent data-testid="certificate-upload-dialog" className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('certificates.uploadCertificate')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||
<Input
|
||||
id="certificate-name"
|
||||
label={t('certificates.friendlyName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. My Custom Cert"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
|
||||
<FileDropZone
|
||||
id="cert-file"
|
||||
label={t('certificates.certificateFile')}
|
||||
accept=".pem,.crt,.cer,.pfx,.p12,.der"
|
||||
file={certFile}
|
||||
onFileChange={(f) => {
|
||||
setCertFile(f)
|
||||
setValidationResult(null)
|
||||
}}
|
||||
required
|
||||
formatBadge={certFormat}
|
||||
/>
|
||||
|
||||
{isPfx && (
|
||||
<p className="text-xs text-content-muted italic">
|
||||
{t('certificates.pfxDetected')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isPfx && (
|
||||
<>
|
||||
<FileDropZone
|
||||
id="key-file"
|
||||
label={t('certificates.privateKeyFile')}
|
||||
accept=".pem,.key"
|
||||
file={keyFile}
|
||||
onFileChange={(f) => {
|
||||
setKeyFile(f)
|
||||
setValidationResult(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
<FileDropZone
|
||||
id="chain-file"
|
||||
label={t('certificates.chainFile')}
|
||||
accept=".pem,.crt,.cer"
|
||||
file={chainFile}
|
||||
onFileChange={(f) => {
|
||||
setChainFile(f)
|
||||
setValidationResult(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{certFile && !validationResult && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleValidate}
|
||||
disabled={!canValidate}
|
||||
isLoading={validateMutation.isPending}
|
||||
data-testid="validate-certificate-btn"
|
||||
>
|
||||
{validateMutation.isPending
|
||||
? t('certificates.validating')
|
||||
: t('certificates.validate')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{validationResult && (
|
||||
<CertificateValidationPreview result={validationResult} />
|
||||
)}
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => handleClose(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
isLoading={uploadMutation.isPending}
|
||||
data-testid="upload-certificate-submit"
|
||||
>
|
||||
{t('certificates.uploadAndSave')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export default function DeleteCertificateDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('certificates.deleteTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{certificate.name || certificate.domain}
|
||||
{certificate.name || certificate.domains}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function DeleteCertificateDialog({
|
||||
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
<dt className="text-gray-500">{t('certificates.domain')}</dt>
|
||||
<dd className="text-white">{certificate.domain}</dd>
|
||||
<dd className="text-white">{certificate.domains}</dd>
|
||||
<dt className="text-gray-500">{t('certificates.status')}</dt>
|
||||
<dd className="text-white capitalize">{certificate.status}</dd>
|
||||
<dt className="text-gray-500">{t('certificates.provider')}</dt>
|
||||
|
||||
@@ -7,20 +7,22 @@ import BulkDeleteCertificateDialog from '../../dialogs/BulkDeleteCertificateDial
|
||||
import type { Certificate } from '../../../api/certificates'
|
||||
|
||||
const makeCert = (overrides: Partial<Certificate>): Certificate => ({
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
name: 'Test Cert',
|
||||
domain: 'test.example.com',
|
||||
domains: 'test.example.com',
|
||||
issuer: 'Custom CA',
|
||||
expires_at: '2026-01-01T00:00:00Z',
|
||||
status: 'valid',
|
||||
provider: 'custom',
|
||||
has_key: true,
|
||||
in_use: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const certs: Certificate[] = [
|
||||
makeCert({ id: 1, name: 'Cert One', domain: 'one.example.com' }),
|
||||
makeCert({ id: 2, name: 'Cert Two', domain: 'two.example.com', provider: 'letsencrypt-staging', status: 'untrusted' }),
|
||||
makeCert({ id: 3, name: 'Cert Three', domain: 'three.example.com', provider: 'letsencrypt', status: 'expired' }),
|
||||
makeCert({ uuid: 'cert-1', name: 'Cert One', domains: 'one.example.com' }),
|
||||
makeCert({ uuid: 'cert-2', name: 'Cert Two', domains: 'two.example.com', provider: 'letsencrypt-staging', status: 'untrusted' }),
|
||||
makeCert({ uuid: 'cert-3', name: 'Cert Three', domains: 'three.example.com', provider: 'letsencrypt', status: 'expired' }),
|
||||
]
|
||||
|
||||
describe('BulkDeleteCertificateDialog', () => {
|
||||
@@ -121,7 +123,7 @@ describe('BulkDeleteCertificateDialog', () => {
|
||||
})
|
||||
|
||||
it('renders "Expiring LE" label for a letsencrypt cert with status expiring', () => {
|
||||
const expiringCert = makeCert({ id: 4, name: 'Expiring Cert', domain: 'expiring.example.com', provider: 'letsencrypt', status: 'expiring' })
|
||||
const expiringCert = makeCert({ uuid: 'cert-4', name: 'Expiring Cert', domains: 'expiring.example.com', provider: 'letsencrypt', status: 'expiring' })
|
||||
render(
|
||||
<BulkDeleteCertificateDialog
|
||||
certificates={[expiringCert]}
|
||||
|
||||
@@ -14,13 +14,15 @@ vi.mock('react-i18next', () => ({
|
||||
}))
|
||||
|
||||
const baseCert: Certificate = {
|
||||
id: 1,
|
||||
uuid: 'cert-1',
|
||||
name: 'Test Cert',
|
||||
domain: 'test.example.com',
|
||||
domains: 'test.example.com',
|
||||
issuer: 'Custom CA',
|
||||
expires_at: '2026-01-01T00:00:00Z',
|
||||
status: 'valid',
|
||||
provider: 'custom',
|
||||
has_key: true,
|
||||
in_use: false,
|
||||
}
|
||||
|
||||
describe('DeleteCertificateDialog', () => {
|
||||
|
||||
135
frontend/src/components/ui/FileDropZone.tsx
Normal file
135
frontend/src/components/ui/FileDropZone.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Upload } from 'lucide-react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
interface FileDropZoneProps {
|
||||
id: string
|
||||
label: string
|
||||
accept?: string
|
||||
file: File | null
|
||||
onFileChange: (file: File | null) => void
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
formatBadge?: string | null
|
||||
}
|
||||
|
||||
export function FileDropZone({
|
||||
id,
|
||||
label,
|
||||
accept,
|
||||
file,
|
||||
onFileChange,
|
||||
disabled = false,
|
||||
required = false,
|
||||
formatBadge,
|
||||
}: FileDropZoneProps) {
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
if (disabled) return
|
||||
const droppedFile = e.dataTransfer.files[0]
|
||||
if (droppedFile) onFileChange(droppedFile)
|
||||
},
|
||||
[disabled, onFileChange],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!disabled) setIsDragOver(true)
|
||||
},
|
||||
[disabled],
|
||||
)
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDragOver(false)
|
||||
}, [])
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0] || null
|
||||
onFileChange(selected)
|
||||
},
|
||||
[onFileChange],
|
||||
)
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!disabled) inputRef.current?.click()
|
||||
}, [disabled])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
|
||||
e.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
},
|
||||
[disabled],
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-content-secondary mb-1.5">
|
||||
{label}
|
||||
{required && <span className="text-error ml-0.5" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={file ? `${label}: ${file.name}` : label}
|
||||
aria-disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-elevated',
|
||||
isDragOver && !disabled
|
||||
? 'border-brand-500 bg-brand-500/10'
|
||||
: 'border-gray-700 hover:border-gray-600 bg-surface-muted/30',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleInputChange}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
aria-required={required}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{file ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Upload className="h-4 w-4 text-brand-400" aria-hidden="true" />
|
||||
<span className="text-content-primary font-medium truncate max-w-[200px]">
|
||||
{file.name}
|
||||
</span>
|
||||
{formatBadge && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-500/20 text-brand-400 border border-brand-500/30">
|
||||
{formatBadge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-sm text-content-muted">
|
||||
<Upload className="h-5 w-5 mb-1" aria-hidden="true" />
|
||||
<span>{t('certificates.dropFileHere')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { getCertificates } from '../api/certificates'
|
||||
import {
|
||||
getCertificates,
|
||||
getCertificateDetail,
|
||||
uploadCertificate,
|
||||
updateCertificate,
|
||||
deleteCertificate,
|
||||
exportCertificate,
|
||||
validateCertificate,
|
||||
} from '../api/certificates'
|
||||
|
||||
import type { CertificateDetail } from '../api/certificates'
|
||||
|
||||
interface UseCertificatesOptions {
|
||||
refetchInterval?: number | false
|
||||
@@ -20,3 +30,103 @@ export function useCertificates(options?: UseCertificatesOptions) {
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export function useCertificateDetail(uuid: string | null) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['certificates', uuid],
|
||||
queryFn: () => getCertificateDetail(uuid!),
|
||||
enabled: !!uuid,
|
||||
})
|
||||
|
||||
return {
|
||||
detail: data as CertificateDetail | undefined,
|
||||
isLoading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
export function useUploadCertificate() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: {
|
||||
name: string
|
||||
certFile: File
|
||||
keyFile?: File
|
||||
chainFile?: File
|
||||
}) => uploadCertificate(params.name, params.certFile, params.keyFile, params.chainFile),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateCertificate() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: { uuid: string; name: string }) =>
|
||||
updateCertificate(params.uuid, params.name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteCertificate() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (uuid: string) => deleteCertificate(uuid),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useExportCertificate() {
|
||||
return useMutation({
|
||||
mutationFn: (params: {
|
||||
uuid: string
|
||||
format: string
|
||||
includeKey: boolean
|
||||
password?: string
|
||||
pfxPassword?: string
|
||||
}) =>
|
||||
exportCertificate(
|
||||
params.uuid,
|
||||
params.format,
|
||||
params.includeKey,
|
||||
params.password,
|
||||
params.pfxPassword,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export function useValidateCertificate() {
|
||||
return useMutation({
|
||||
mutationFn: (params: {
|
||||
certFile: File
|
||||
keyFile?: File
|
||||
chainFile?: File
|
||||
}) => validateCertificate(params.certFile, params.keyFile, params.chainFile),
|
||||
})
|
||||
}
|
||||
|
||||
export function useBulkDeleteCertificates() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (uuids: string[]) => {
|
||||
const results = await Promise.allSettled(uuids.map(uuid => deleteCertificate(uuid)))
|
||||
const failed = results.filter(r => r.status === 'rejected').length
|
||||
const succeeded = results.filter(r => r.status === 'fulfilled').length
|
||||
return { succeeded, failed }
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -207,7 +207,68 @@
|
||||
"providerStaging": "Staging",
|
||||
"providerCustom": "Custom",
|
||||
"providerExpiredLE": "Expired LE",
|
||||
"providerExpiringLE": "Expiring LE"
|
||||
"providerExpiringLE": "Expiring LE",
|
||||
|
||||
"certificateFile": "Certificate File",
|
||||
"privateKeyFile": "Private Key File",
|
||||
"chainFile": "Chain File (Optional)",
|
||||
"dropFileHere": "Drag and drop a file here, or click to browse",
|
||||
"formatDetected": "Detected: {{format}}",
|
||||
"pfxDetected": "PFX/PKCS#12 detected — key is embedded, no separate key file needed.",
|
||||
"pfxPassword": "PFX Password (if protected)",
|
||||
|
||||
"validate": "Validate",
|
||||
"validating": "Validating...",
|
||||
"validationPreview": "Validation Preview",
|
||||
"commonName": "Common Name",
|
||||
"domains": "Domains",
|
||||
"issuerOrg": "Issuer",
|
||||
"keyMatch": "Key Match",
|
||||
"chainValid": "Chain Valid",
|
||||
"chainDepth": "Chain Depth",
|
||||
"warnings": "Warnings",
|
||||
"errors": "Errors",
|
||||
"validCertificate": "Valid certificate",
|
||||
"invalidCertificate": "Certificate has errors",
|
||||
"uploadAndSave": "Upload & Save",
|
||||
|
||||
"detailTitle": "Certificate Details",
|
||||
"fingerprint": "Fingerprint",
|
||||
"serialNumber": "Serial Number",
|
||||
"keyType": "Key Type",
|
||||
"notBefore": "Valid From",
|
||||
"autoRenew": "Auto Renew",
|
||||
"createdAt": "Created",
|
||||
"updatedAt": "Last Updated",
|
||||
"assignedHosts": "Assigned Hosts",
|
||||
"noAssignedHosts": "Not assigned to any proxy host",
|
||||
"certificateChain": "Certificate Chain",
|
||||
"noChainData": "No chain data available",
|
||||
"chainLeaf": "Leaf",
|
||||
"chainIntermediate": "Intermediate",
|
||||
"chainRoot": "Root",
|
||||
|
||||
"exportTitle": "Export Certificate",
|
||||
"exportFormat": "Format",
|
||||
"exportFormatPem": "PEM",
|
||||
"exportFormatPfx": "PFX/PKCS#12",
|
||||
"exportFormatDer": "DER",
|
||||
"includePrivateKey": "Include Private Key",
|
||||
"includePrivateKeyWarning": "Exporting the private key requires re-authentication.",
|
||||
"exportPassword": "Account Password",
|
||||
"exportPfxPassword": "PFX Password",
|
||||
"exportButton": "Export",
|
||||
"exportSuccess": "Certificate exported",
|
||||
"exportFailed": "Failed to export certificate",
|
||||
|
||||
"expiresInDays": "Expires in {{days}} days",
|
||||
"expiredAgo": "Expired {{days}} days ago",
|
||||
"viewDetails": "View details",
|
||||
"export": "Export",
|
||||
|
||||
"updateName": "Rename Certificate",
|
||||
"updateSuccess": "Certificate renamed",
|
||||
"updateFailed": "Failed to rename certificate"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
|
||||
@@ -1,59 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, ShieldCheck } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { uploadCertificate } from '../api/certificates'
|
||||
import CertificateList from '../components/CertificateList'
|
||||
import CertificateUploadDialog from '../components/dialogs/CertificateUploadDialog'
|
||||
import { PageShell } from '../components/layout/PageShell'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Label,
|
||||
} from '../components/ui'
|
||||
import { toast } from '../utils/toast'
|
||||
import { Button, Alert } from '../components/ui'
|
||||
|
||||
export default function Certificates() {
|
||||
const { t } = useTranslation()
|
||||
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 [isUploadOpen, setIsUploadOpen] = useState(false)
|
||||
|
||||
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(t('certificates.uploadSuccess'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`${t('certificates.uploadFailed')}: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
uploadMutation.mutate()
|
||||
}
|
||||
|
||||
// Header actions
|
||||
const headerActions = (
|
||||
<Button onClick={() => setIsModalOpen(true)} data-testid="add-certificate-btn">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<Button onClick={() => setIsUploadOpen(true)} data-testid="add-certificate-btn">
|
||||
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
{t('certificates.addCertificate')}
|
||||
</Button>
|
||||
)
|
||||
@@ -70,56 +30,7 @@ export default function Certificates() {
|
||||
|
||||
<CertificateList />
|
||||
|
||||
{/* Upload Certificate Dialog */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent data-testid="certificate-upload-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('certificates.uploadCertificate')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-4">
|
||||
<Input
|
||||
id="certificate-name"
|
||||
label={t('certificates.friendlyName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. My Custom Cert"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="cert-file">{t('certificates.certificatePem')}</Label>
|
||||
<input
|
||||
id="cert-file"
|
||||
data-testid="certificate-file-input"
|
||||
type="file"
|
||||
accept=".pem,.crt,.cer"
|
||||
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
|
||||
className="mt-1.5 block w-full text-sm text-content-secondary file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-500 file:text-white hover:file:bg-brand-600 cursor-pointer"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="key-file">{t('certificates.privateKeyPem')}</Label>
|
||||
<input
|
||||
id="key-file"
|
||||
data-testid="certificate-key-input"
|
||||
type="file"
|
||||
accept=".pem,.key"
|
||||
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
|
||||
className="mt-1.5 block w-full text-sm text-content-secondary file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-500 file:text-white hover:file:bg-brand-600 cursor-pointer"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="pt-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={uploadMutation.isPending}>
|
||||
{t('common.upload')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CertificateUploadDialog open={isUploadOpen} onOpenChange={setIsUploadOpen} />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ export default function Dashboard() {
|
||||
const certifiedDomains = new Set<string>()
|
||||
for (const cert of certificates) {
|
||||
// Handle missing or undefined domain field
|
||||
if (!cert.domain) continue
|
||||
for (const d of cert.domain.split(',')) {
|
||||
if (!cert.domains) continue
|
||||
for (const d of cert.domains.split(',')) {
|
||||
const trimmed = d.trim().toLowerCase()
|
||||
if (trimmed) certifiedDomains.add(trimmed)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function ProxyHosts() {
|
||||
const [certCleanupData, setCertCleanupData] = useState<{
|
||||
hostUUIDs: string[]
|
||||
hostNames: string[]
|
||||
certificates: Array<{ id: number; name: string; domain: string }>
|
||||
certificates: Array<{ uuid: string; name: string; domain: string }>
|
||||
isBulk: boolean
|
||||
} | null>(null)
|
||||
const [selectedACLs, setSelectedACLs] = useState<Set<number>>(new Set())
|
||||
@@ -103,7 +103,7 @@ export default function ProxyHosts() {
|
||||
const certStatusByDomain = useMemo(() => {
|
||||
const map: Record<string, { status: string; provider: string }> = {}
|
||||
for (const cert of certificates) {
|
||||
const domains = cert.domain.split(',').map(d => d.trim().toLowerCase())
|
||||
const domains = cert.domains.split(',').map(d => d.trim().toLowerCase())
|
||||
for (const domain of domains) {
|
||||
if (!map[domain]) {
|
||||
map[domain] = { status: cert.status, provider: cert.provider }
|
||||
@@ -148,7 +148,7 @@ export default function ProxyHosts() {
|
||||
const host = hostToDelete
|
||||
|
||||
// Check for orphaned certificates that would need cleanup
|
||||
const orphanedCerts: Array<{ id: number; name: string; domain: string }> = []
|
||||
const orphanedCerts: Array<{ uuid: string; name: string; domain: string }> = []
|
||||
|
||||
if (host.certificate_id && host.certificate) {
|
||||
const cert = host.certificate
|
||||
@@ -160,7 +160,7 @@ export default function ProxyHosts() {
|
||||
const isCustomOrStaging = cert.provider === 'custom' || cert.provider?.toLowerCase().includes('staging')
|
||||
if (isCustomOrStaging) {
|
||||
orphanedCerts.push({
|
||||
id: cert.id!,
|
||||
uuid: cert.uuid,
|
||||
name: cert.name || '',
|
||||
domain: cert.domains
|
||||
})
|
||||
@@ -237,7 +237,7 @@ export default function ProxyHosts() {
|
||||
|
||||
for (const cert of certCleanupData.certificates) {
|
||||
try {
|
||||
await deleteCertificate(cert.id)
|
||||
await deleteCertificate(cert.uuid)
|
||||
certsDeleted++
|
||||
} catch {
|
||||
certsFailed++
|
||||
@@ -282,7 +282,7 @@ export default function ProxyHosts() {
|
||||
// Delete certificate if user confirmed
|
||||
if (deleteCerts && certCleanupData.certificates.length > 0) {
|
||||
try {
|
||||
await deleteCertificate(certCleanupData.certificates[0].id)
|
||||
await deleteCertificate(certCleanupData.certificates[0].uuid)
|
||||
toast.success('Proxy host and certificate deleted')
|
||||
} catch (err) {
|
||||
toast.error(`Proxy host deleted but failed to delete certificate: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
@@ -329,7 +329,7 @@ export default function ProxyHosts() {
|
||||
toast.success(`Backup created: ${backup.filename}`)
|
||||
|
||||
// Collect certificates to potentially delete
|
||||
const certsToConsider: Map<number, { id: number; name: string; domain: string }> = new Map()
|
||||
const certsToConsider: Map<string, { uuid: string; name: string; domain: string }> = new Map()
|
||||
|
||||
for (const uuid of hostUUIDs) {
|
||||
const host = hosts.find(h => h.uuid === uuid)
|
||||
@@ -343,9 +343,9 @@ export default function ProxyHosts() {
|
||||
h.certificate_id === host.certificate_id &&
|
||||
!hostUUIDs.includes(h.uuid)
|
||||
)
|
||||
if (otherHosts.length === 0 && cert.id) {
|
||||
certsToConsider.set(cert.id, {
|
||||
id: cert.id,
|
||||
if (otherHosts.length === 0 && cert.uuid) {
|
||||
certsToConsider.set(cert.uuid, {
|
||||
uuid: cert.uuid,
|
||||
name: cert.name || '',
|
||||
domain: cert.domains
|
||||
})
|
||||
|
||||
@@ -1,55 +1,27 @@
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { uploadCertificate, type Certificate } from '../../api/certificates'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import { toast } from '../../utils/toast'
|
||||
import Certificates from '../Certificates'
|
||||
|
||||
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
'certificates.addCertificate': 'Add Certificate',
|
||||
'certificates.uploadCertificate': 'Upload Certificate',
|
||||
'certificates.friendlyName': 'Friendly Name',
|
||||
'certificates.certificatePem': 'Certificate (PEM)',
|
||||
'certificates.privateKeyPem': 'Private Key (PEM)',
|
||||
'certificates.uploadSuccess': 'Certificate uploaded successfully',
|
||||
'certificates.uploadFailed': 'Failed to upload certificate',
|
||||
'common.upload': 'Upload',
|
||||
'common.cancel': 'Cancel',
|
||||
}
|
||||
|
||||
const t = (key: string, options?: Record<string, unknown>) => {
|
||||
const template = translations[key] ?? key
|
||||
|
||||
if (!options) return template
|
||||
|
||||
return Object.entries(options).reduce((acc, [optionKey, optionValue]) => {
|
||||
return acc.replace(`{{${optionKey}}}`, String(optionValue))
|
||||
}, template)
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t,
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../components/CertificateList', () => ({
|
||||
default: () => <div>CertificateList</div>,
|
||||
default: () => <div data-testid="certificate-list">CertificateList</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
uploadCertificate: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
vi.mock('../../components/dialogs/CertificateUploadDialog', () => ({
|
||||
default: ({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) =>
|
||||
open ? (
|
||||
<div role="dialog" data-testid="upload-dialog">
|
||||
<button onClick={() => onOpenChange(false)}>Close</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
describe('Certificates', () => {
|
||||
@@ -57,93 +29,35 @@ describe('Certificates', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uploads certificate and closes dialog on success', async () => {
|
||||
const certificate: Certificate = {
|
||||
domain: 'example.com',
|
||||
issuer: 'Test CA',
|
||||
expires_at: '2026-03-01T00:00:00Z',
|
||||
status: 'valid',
|
||||
provider: 'custom',
|
||||
}
|
||||
vi.mocked(uploadCertificate).mockResolvedValue(certificate)
|
||||
|
||||
const user = userEvent.setup()
|
||||
const { queryClient } = renderWithQueryClient(<Certificates />)
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') })
|
||||
|
||||
const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement
|
||||
await user.type(nameInput, 'My Cert')
|
||||
await waitFor(() => {
|
||||
expect(nameInput.value).toBe('My Cert')
|
||||
})
|
||||
|
||||
const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' })
|
||||
const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' })
|
||||
|
||||
const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement
|
||||
const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement
|
||||
|
||||
await user.upload(certInput, certFile)
|
||||
await user.upload(keyInput, keyFile)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(certInput.files?.[0]).toBe(certFile)
|
||||
expect(keyInput.files?.[0]).toBe(keyFile)
|
||||
})
|
||||
|
||||
const form = dialog.querySelector('form') as HTMLFormElement
|
||||
fireEvent.submit(form)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadCertificate).toHaveBeenCalledWith('My Cert', certFile, keyFile)
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['certificates'] })
|
||||
expect(toast.success).toHaveBeenCalledWith(t('certificates.uploadSuccess'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: t('certificates.uploadCertificate') })).not.toBeInTheDocument()
|
||||
})
|
||||
it('renders the page with certificate list and add button', () => {
|
||||
renderWithQueryClient(<Certificates />)
|
||||
expect(screen.getByText('certificates.addCertificate')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('certificate-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('surfaces upload errors', async () => {
|
||||
vi.mocked(uploadCertificate).mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
it('opens upload dialog when add button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Certificates />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: t('certificates.addCertificate') }))
|
||||
expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument()
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: t('certificates.uploadCertificate') })
|
||||
await user.click(screen.getByRole('button', { name: 'certificates.addCertificate' }))
|
||||
expect(screen.getByTestId('upload-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const nameInput = within(dialog).getByLabelText(t('certificates.friendlyName')) as HTMLInputElement
|
||||
await user.type(nameInput, 'My Cert')
|
||||
await waitFor(() => {
|
||||
expect(nameInput.value).toBe('My Cert')
|
||||
})
|
||||
it('closes upload dialog via onOpenChange callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Certificates />)
|
||||
|
||||
const certFile = new File(['cert'], 'cert.pem', { type: 'application/x-pem-file' })
|
||||
const keyFile = new File(['key'], 'key.pem', { type: 'application/x-pem-file' })
|
||||
await user.click(screen.getByRole('button', { name: 'certificates.addCertificate' }))
|
||||
expect(screen.getByTestId('upload-dialog')).toBeInTheDocument()
|
||||
|
||||
const certInput = within(dialog).getByLabelText(t('certificates.certificatePem')) as HTMLInputElement
|
||||
const keyInput = within(dialog).getByLabelText(t('certificates.privateKeyPem')) as HTMLInputElement
|
||||
await user.click(screen.getByText('Close'))
|
||||
expect(screen.queryByTestId('upload-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.upload(certInput, certFile)
|
||||
await user.upload(keyInput, keyFile)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(certInput.files?.[0]).toBe(certFile)
|
||||
expect(keyInput.files?.[0]).toBe(keyFile)
|
||||
})
|
||||
|
||||
const form = dialog.querySelector('form') as HTMLFormElement
|
||||
fireEvent.submit(form)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(`${t('certificates.uploadFailed')}: Upload failed`)
|
||||
})
|
||||
it('renders info alert with note text', () => {
|
||||
renderWithQueryClient(<Certificates />)
|
||||
expect(screen.getByText('certificates.noteText')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -485,8 +485,8 @@ describe('ProxyHosts - Coverage enhancements', () => {
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([hostCustom, hostStaging, hostAuto, hostLets])
|
||||
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([
|
||||
{ domain: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' },
|
||||
{ domain: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01' },
|
||||
{ uuid: 'cert-staging', domains: 'staging.com', status: 'untrusted', provider: 'letsencrypt-staging', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01', has_key: false, in_use: true },
|
||||
{ uuid: 'cert-lets', domains: 'lets.com', status: 'valid', provider: 'letsencrypt', issuer: 'Let\'s Encrypt', expires_at: '2026-01-01', has_key: false, in_use: true },
|
||||
])
|
||||
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
|
||||
|
||||
@@ -190,12 +190,15 @@ describe('ProxyHosts page extra tests', () => {
|
||||
certificates: [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'cert-le-1',
|
||||
name: 'LE',
|
||||
domain: 'valid.example.com',
|
||||
domains: 'valid.example.com',
|
||||
issuer: 'letsencrypt',
|
||||
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'valid',
|
||||
provider: 'letsencrypt',
|
||||
has_key: false,
|
||||
in_use: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user