import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Plus, Edit, Trash2, CheckCircle, XCircle, TestTube } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, Button, Input, Label, Checkbox, EmptyState, } from './ui' import { useCredentials, useCreateCredential, useUpdateCredential, useDeleteCredential, useTestCredential, type DNSProviderCredential, type CredentialRequest, } from '../hooks/useCredentials' import type { DNSProvider, DNSProviderTypeInfo } from '../api/dnsProviders' import { toast } from '../utils/toast' interface CredentialManagerProps { open: boolean onOpenChange: (open: boolean) => void provider: DNSProvider providerTypeInfo?: DNSProviderTypeInfo } export default function CredentialManager({ open, onOpenChange, provider, providerTypeInfo, }: CredentialManagerProps) { const { t } = useTranslation() const { data: credentials = [], isLoading, refetch } = useCredentials(provider.id) const deleteMutation = useDeleteCredential() const testMutation = useTestCredential() const [isFormOpen, setIsFormOpen] = useState(false) const [editingCredential, setEditingCredential] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null) const [testingId, setTestingId] = useState(null) const handleAddCredential = () => { setEditingCredential(null) setIsFormOpen(true) } const handleEditCredential = (credential: DNSProviderCredential) => { setEditingCredential(credential) setIsFormOpen(true) } const handleDeleteClick = (id: number) => { setDeleteConfirm(id) } const handleDeleteConfirm = async (id: number) => { try { await deleteMutation.mutateAsync({ providerId: provider.id, credentialId: id }) toast.success(t('credentials.deleteSuccess', 'Credential deleted successfully')) setDeleteConfirm(null) refetch() } catch (error: unknown) { const err = error as { response?: { data?: { error?: string } }; message?: string } toast.error( t('credentials.deleteFailed', 'Failed to delete credential') + ': ' + (err.response?.data?.error || err.message) ) } } const handleTestCredential = async (id: number) => { setTestingId(id) try { const result = await testMutation.mutateAsync({ providerId: provider.id, credentialId: id, }) if (result.success) { toast.success(result.message || t('credentials.testSuccess', 'Credential test passed')) } else { toast.error(result.error || t('credentials.testFailed', 'Credential test failed')) } refetch() } catch (error: unknown) { const err = error as { response?: { data?: { error?: string } }; message?: string } toast.error( t('credentials.testFailed', 'Failed to test credential') + ': ' + (err.response?.data?.error || err.message) ) } finally { setTestingId(null) } } const handleFormSuccess = () => { toast.success( editingCredential ? t('credentials.updateSuccess', 'Credential updated successfully') : t('credentials.createSuccess', 'Credential created successfully') ) setIsFormOpen(false) refetch() } return ( {t('credentials.manageTitle', 'Manage Credentials')}: {provider.name}
{/* Add Button */}
{/* Loading State */} {isLoading && (
{t('common.loading', 'Loading...')}
)} {/* Empty State */} {!isLoading && credentials.length === 0 && ( } title={t('credentials.noCredentials', 'No credentials configured')} description={t( 'credentials.noCredentialsDescription', 'Add credentials to enable zone-specific DNS challenge configuration' )} action={{ label: t('credentials.addFirst', 'Add First Credential'), onClick: handleAddCredential, }} /> )} {/* Credentials Table */} {!isLoading && credentials.length > 0 && (
{credentials.map((credential) => ( ))}
{t('credentials.label', 'Label')} {t('credentials.zones', 'Zones')} {t('credentials.status', 'Status')} {t('common.actions', 'Actions')}
{credential.label}
{!credential.enabled && ( {t('common.disabled', 'Disabled')} )}
{credential.zone_filter || ( {t('credentials.allZones', 'All zones (catch-all)')} )}
{credential.failure_count > 0 ? ( ) : ( )} {credential.success_count}/{credential.failure_count}
{credential.last_used_at && (
{t('credentials.lastUsed', 'Last used')}:{' '} {new Date(credential.last_used_at).toLocaleString()}
)} {credential.last_error && (
{credential.last_error}
)}
)}
{/* Credential Form Dialog */} {isFormOpen && ( )} {/* Delete Confirmation Dialog */} {deleteConfirm !== null && ( setDeleteConfirm(null)}> {t('credentials.deleteConfirm', 'Delete Credential?')}

{t( 'credentials.deleteWarning', 'Are you sure you want to delete this credential? This action cannot be undone.' )}

)}
) } interface CredentialFormProps { open: boolean onOpenChange: (open: boolean) => void providerId: number providerTypeInfo?: DNSProviderTypeInfo credential: DNSProviderCredential | null onSuccess: () => void } function CredentialForm({ open, onOpenChange, providerId, providerTypeInfo, credential, onSuccess, }: CredentialFormProps) { const { t } = useTranslation() const createMutation = useCreateCredential() const updateMutation = useUpdateCredential() const testMutation = useTestCredential() const [label, setLabel] = useState('') const [zoneFilter, setZoneFilter] = useState('') const [credentials, setCredentials] = useState>({}) const [propagationTimeout, setPropagationTimeout] = useState(120) const [pollingInterval, setPollingInterval] = useState(5) const [enabled, setEnabled] = useState(true) const [errors, setErrors] = useState>({}) useEffect(() => { if (credential) { setLabel(credential.label) setZoneFilter(credential.zone_filter) setPropagationTimeout(credential.propagation_timeout) setPollingInterval(credential.polling_interval) setEnabled(credential.enabled) setCredentials({}) // Don't pre-fill credentials (they're encrypted) } else { resetForm() } }, [credential, open]) const resetForm = () => { setLabel('') setZoneFilter('') setCredentials({}) setPropagationTimeout(120) setPollingInterval(5) setEnabled(true) setErrors({}) } const validateZoneFilter = (value: string): boolean => { if (!value) return true // Empty is valid (catch-all) const zones = value.split(',').map((z) => z.trim()) for (const zone of zones) { // Basic domain validation if (zone && !/^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(zone)) { setErrors((prev) => ({ ...prev, zone_filter: t('credentials.invalidZone', 'Invalid domain format: ') + zone, })) return false } } setErrors((prev) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { zone_filter: _, ...rest } = prev return rest }) return true } const handleCredentialChange = (fieldName: string, value: string) => { setCredentials((prev) => ({ ...prev, [fieldName]: value })) } const handleSubmit = async () => { // Validate if (!label.trim()) { setErrors({ label: t('credentials.labelRequired', 'Label is required') }) return } if (!validateZoneFilter(zoneFilter)) { return } // Check required credential fields const missingFields: string[] = [] providerTypeInfo?.fields .filter((f) => f.required) .forEach((field) => { if (!credentials[field.name]) { missingFields.push(field.label) } }) if (missingFields.length > 0 && !credential) { // Only enforce for new credentials toast.error( t('credentials.missingFields', 'Missing required fields: ') + missingFields.join(', ') ) return } const data: CredentialRequest = { label: label.trim(), zone_filter: zoneFilter.trim(), credentials, propagation_timeout: propagationTimeout, polling_interval: pollingInterval, enabled, } try { if (credential) { await updateMutation.mutateAsync({ providerId, credentialId: credential.id, data, }) } else { await createMutation.mutateAsync({ providerId, data }) } onSuccess() } catch (error: unknown) { const err = error as { response?: { data?: { error?: string } }; message?: string } toast.error( t('credentials.saveFailed', 'Failed to save credential') + ': ' + (err.response?.data?.error || err.message) ) } } const handleTest = async () => { if (!credential) { toast.info(t('credentials.saveBeforeTest', 'Please save the credential before testing')) return } try { const result = await testMutation.mutateAsync({ providerId, credentialId: credential.id, }) if (result.success) { toast.success(result.message || t('credentials.testSuccess', 'Test passed')) } else { toast.error(result.error || t('credentials.testFailed', 'Test failed')) } } catch (error: unknown) { const err = error as { response?: { data?: { error?: string } }; message?: string } toast.error( t('credentials.testFailed', 'Test failed') + ': ' + (err.response?.data?.error || err.message) ) } } return ( {credential ? t('credentials.editCredential', 'Edit Credential') : t('credentials.addCredential', 'Add Credential')}
{/* Label */}
setLabel(e.target.value)} placeholder={t('credentials.labelPlaceholder', 'e.g., Production, Customer A')} error={errors.label} />
{/* Zone Filter */}
{ setZoneFilter(e.target.value) validateZoneFilter(e.target.value) }} placeholder="example.com, *.staging.example.com" error={errors.zone_filter} />

{t( 'credentials.zoneFilterHint', 'Comma-separated domains. Leave empty for catch-all. Supports wildcards (*.example.com)' )}

{/* Credentials Fields */} {providerTypeInfo?.fields.map((field) => (
handleCredentialChange(field.name, e.target.value)} placeholder={ credential ? t('credentials.leaveBlankToKeep', '(leave blank to keep existing)') : field.default || '' } /> {field.hint && (

{field.hint}

)}
))} {/* Enabled Checkbox */}
setEnabled(checked === true)} />
{/* Advanced Options */}
{t('common.advancedOptions', 'Advanced Options')}
setPropagationTimeout(parseInt(e.target.value) || 120)} />
setPollingInterval(parseInt(e.target.value) || 5)} />
{credential && ( )}
) }