Files
Charon/frontend/src/components/CredentialManager.tsx
2026-03-04 18:34:49 +00:00

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>
)
}