chore: clean .gitignore cache
This commit is contained in:
@@ -1,609 +0,0 @@
|
||||
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={deleteConfirm !== null} 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user