610 lines
20 KiB
TypeScript
610 lines
20 KiB
TypeScript
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<DNSProviderCredential | null>(null)
|
|
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
|
const [testingId, setTestingId] = useState<number | null>(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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t('credentials.manageTitle', 'Manage Credentials')}: {provider.name}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* Add Button */}
|
|
<div className="flex justify-between items-center">
|
|
<Button onClick={handleAddCredential} size="sm">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
{t('credentials.addCredential', 'Add Credential')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Loading State */}
|
|
{isLoading && (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
{t('common.loading', 'Loading...')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!isLoading && credentials.length === 0 && (
|
|
<EmptyState
|
|
icon={<CheckCircle className="w-10 h-10" />}
|
|
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 && (
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<table className="w-full">
|
|
<thead className="bg-muted">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">
|
|
{t('credentials.label', 'Label')}
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">
|
|
{t('credentials.zones', 'Zones')}
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">
|
|
{t('credentials.status', 'Status')}
|
|
</th>
|
|
<th className="px-4 py-3 text-right text-sm font-medium">
|
|
{t('common.actions', 'Actions')}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{credentials.map((credential) => (
|
|
<tr key={credential.id} className="hover:bg-muted/50">
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium">{credential.label}</div>
|
|
{!credential.enabled && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{t('common.disabled', 'Disabled')}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm">
|
|
{credential.zone_filter || (
|
|
<span className="text-muted-foreground italic">
|
|
{t('credentials.allZones', 'All zones (catch-all)')}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
{credential.failure_count > 0 ? (
|
|
<XCircle className="w-4 h-4 text-destructive" />
|
|
) : (
|
|
<CheckCircle className="w-4 h-4 text-success" />
|
|
)}
|
|
<span className="text-sm">
|
|
{credential.success_count}/{credential.failure_count}
|
|
</span>
|
|
</div>
|
|
{credential.last_used_at && (
|
|
<div className="text-xs text-muted-foreground">
|
|
{t('credentials.lastUsed', 'Last used')}:{' '}
|
|
{new Date(credential.last_used_at).toLocaleString()}
|
|
</div>
|
|
)}
|
|
{credential.last_error && (
|
|
<div className="text-xs text-destructive mt-1">
|
|
{credential.last_error}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleTestCredential(credential.id)}
|
|
disabled={testingId === credential.id}
|
|
>
|
|
<TestTube className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleEditCredential(credential)}
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleDeleteClick(credential.id)}
|
|
className="text-destructive hover:text-destructive"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
{t('common.close', 'Close')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
|
|
{/* Credential Form Dialog */}
|
|
{isFormOpen && (
|
|
<CredentialForm
|
|
open={isFormOpen}
|
|
onOpenChange={setIsFormOpen}
|
|
providerId={provider.id}
|
|
providerTypeInfo={providerTypeInfo}
|
|
credential={editingCredential}
|
|
onSuccess={handleFormSuccess}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
{deleteConfirm !== null && (
|
|
<Dialog open={true} onOpenChange={() => setDeleteConfirm(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('credentials.deleteConfirm', 'Delete Credential?')}</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t(
|
|
'credentials.deleteWarning',
|
|
'Are you sure you want to delete this credential? This action cannot be undone.'
|
|
)}
|
|
</p>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
|
{t('common.cancel', 'Cancel')}
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
onClick={() => handleDeleteConfirm(deleteConfirm)}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
{t('common.delete', 'Delete')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
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<Record<string, string>>({})
|
|
const [propagationTimeout, setPropagationTimeout] = useState(120)
|
|
const [pollingInterval, setPollingInterval] = useState(5)
|
|
const [enabled, setEnabled] = useState(true)
|
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
|
|
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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{credential
|
|
? t('credentials.editCredential', 'Edit Credential')
|
|
: t('credentials.addCredential', 'Add Credential')}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* Label */}
|
|
<div>
|
|
<Label htmlFor="label">
|
|
{t('credentials.label', 'Label')} <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
id="label"
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
placeholder={t('credentials.labelPlaceholder', 'e.g., Production, Customer A')}
|
|
error={errors.label}
|
|
/>
|
|
</div>
|
|
|
|
{/* Zone Filter */}
|
|
<div>
|
|
<Label htmlFor="zone_filter">{t('credentials.zoneFilter', 'Zone Filter')}</Label>
|
|
<Input
|
|
id="zone_filter"
|
|
value={zoneFilter}
|
|
onChange={(e) => {
|
|
setZoneFilter(e.target.value)
|
|
validateZoneFilter(e.target.value)
|
|
}}
|
|
placeholder="example.com, *.staging.example.com"
|
|
error={errors.zone_filter}
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t(
|
|
'credentials.zoneFilterHint',
|
|
'Comma-separated domains. Leave empty for catch-all. Supports wildcards (*.example.com)'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Credentials Fields */}
|
|
{providerTypeInfo?.fields.map((field) => (
|
|
<div key={field.name}>
|
|
<Label htmlFor={field.name}>
|
|
{field.label} {field.required && <span className="text-destructive">*</span>}
|
|
</Label>
|
|
<Input
|
|
id={field.name}
|
|
type={field.type === 'password' ? 'password' : 'text'}
|
|
value={credentials[field.name] || ''}
|
|
onChange={(e) => handleCredentialChange(field.name, e.target.value)}
|
|
placeholder={
|
|
credential
|
|
? t('credentials.leaveBlankToKeep', '(leave blank to keep existing)')
|
|
: field.default || ''
|
|
}
|
|
/>
|
|
{field.hint && (
|
|
<p className="text-xs text-muted-foreground mt-1">{field.hint}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{/* Enabled Checkbox */}
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="enabled"
|
|
checked={enabled}
|
|
onCheckedChange={(checked) => setEnabled(checked === true)}
|
|
/>
|
|
<Label htmlFor="enabled" className="cursor-pointer">
|
|
{t('credentials.enabled', 'Enabled')}
|
|
</Label>
|
|
</div>
|
|
|
|
{/* Advanced Options */}
|
|
<details className="border rounded-lg p-4">
|
|
<summary className="cursor-pointer font-medium">
|
|
{t('common.advancedOptions', 'Advanced Options')}
|
|
</summary>
|
|
<div className="space-y-4 mt-4">
|
|
<div>
|
|
<Label htmlFor="propagation_timeout">
|
|
{t('dnsProviders.propagationTimeout', 'Propagation Timeout (seconds)')}
|
|
</Label>
|
|
<Input
|
|
id="propagation_timeout"
|
|
type="number"
|
|
min="10"
|
|
max="600"
|
|
value={propagationTimeout}
|
|
onChange={(e) => setPropagationTimeout(parseInt(e.target.value) || 120)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="polling_interval">
|
|
{t('dnsProviders.pollingInterval', 'Polling Interval (seconds)')}
|
|
</Label>
|
|
<Input
|
|
id="polling_interval"
|
|
type="number"
|
|
min="1"
|
|
max="60"
|
|
value={pollingInterval}
|
|
onChange={(e) => setPollingInterval(parseInt(e.target.value) || 5)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
{t('common.cancel', 'Cancel')}
|
|
</Button>
|
|
{credential && (
|
|
<Button
|
|
variant="secondary"
|
|
onClick={handleTest}
|
|
disabled={testMutation.isPending}
|
|
>
|
|
{t('common.test', 'Test')}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={createMutation.isPending || updateMutation.isPending}
|
|
>
|
|
{t('common.save', 'Save')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|