feat: add ManualDNSChallenge component and related hooks for manual DNS challenge management

- Implemented `useManualChallenge`, `useChallengePoll`, and `useManualChallengeMutations` hooks for managing manual DNS challenges.
- Created tests for the `useManualChallenge` hooks to ensure correct fetching and mutation behavior.
- Added `ManualDNSChallenge` component for displaying challenge details and actions.
- Developed end-to-end tests for the Manual DNS Provider feature, covering provider selection, challenge UI, and accessibility compliance.
- Included error handling tests for verification failures and network errors.
This commit is contained in:
GitHub Actions
2026-01-12 04:01:40 +00:00
parent a199dfd079
commit d7939bed70
132 changed files with 8680 additions and 878 deletions
@@ -0,0 +1,479 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
Copy,
Check,
Clock,
RefreshCw,
CheckCircle2,
XCircle,
AlertCircle,
Loader2,
Info,
} from 'lucide-react'
import { Button, Card, CardHeader, CardTitle, CardContent, Progress, Alert } from '../ui'
import { useChallengePoll, useManualChallengeMutations } from '../../hooks/useManualChallenge'
import type { ManualChallenge, ChallengeStatus } from '../../api/manualChallenge'
import { toast } from '../../utils/toast'
interface ManualDNSChallengeProps {
/** The DNS provider ID */
providerId: number
/** Initial challenge data */
challenge: ManualChallenge
/** Callback when challenge is completed or cancelled */
onComplete: (success: boolean) => void
/** Callback when challenge is cancelled */
onCancel: () => void
}
/** Maps challenge status to visual properties */
const STATUS_CONFIG: Record<
ChallengeStatus,
{
icon: typeof CheckCircle2
colorClass: string
labelKey: string
}
> = {
created: {
icon: Clock,
colorClass: 'text-content-muted',
labelKey: 'dnsProvider.manual.status.created',
},
pending: {
icon: Clock,
colorClass: 'text-yellow-500',
labelKey: 'dnsProvider.manual.status.pending',
},
verifying: {
icon: Loader2,
colorClass: 'text-brand-500',
labelKey: 'dnsProvider.manual.status.verifying',
},
verified: {
icon: CheckCircle2,
colorClass: 'text-success',
labelKey: 'dnsProvider.manual.status.verified',
},
expired: {
icon: XCircle,
colorClass: 'text-error',
labelKey: 'dnsProvider.manual.status.expired',
},
failed: {
icon: AlertCircle,
colorClass: 'text-error',
labelKey: 'dnsProvider.manual.status.failed',
},
}
/** Terminal states where polling should stop */
const TERMINAL_STATES: ChallengeStatus[] = ['verified', 'expired', 'failed']
/**
* Formats seconds into MM:SS display format
*/
function formatTimeRemaining(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
/**
* Calculates progress percentage based on time elapsed
*/
function calculateProgress(expiresAt: string, createdAt: string): number {
const now = Date.now()
const created = new Date(createdAt).getTime()
const expires = new Date(expiresAt).getTime()
const total = expires - created
const elapsed = now - created
const remaining = Math.max(0, 100 - (elapsed / total) * 100)
return Math.round(remaining)
}
/**
* Calculate time remaining in seconds
*/
function getTimeRemainingSeconds(expiresAt: string): number {
const now = Date.now()
const expires = new Date(expiresAt).getTime()
return Math.max(0, Math.floor((expires - now) / 1000))
}
export default function ManualDNSChallenge({
providerId,
challenge,
onComplete,
onCancel,
}: ManualDNSChallengeProps) {
const { t } = useTranslation()
const [copiedField, setCopiedField] = useState<'name' | 'value' | null>(null)
const [timeRemaining, setTimeRemaining] = useState(() =>
getTimeRemainingSeconds(challenge.expires_at)
)
const [progress, setProgress] = useState(() =>
calculateProgress(challenge.expires_at, challenge.created_at)
)
const statusAnnouncerRef = useRef<HTMLDivElement>(null)
const previousStatusRef = useRef<ChallengeStatus>(challenge.status)
// Determine if challenge is in a terminal state
const isTerminal = TERMINAL_STATES.includes(challenge.status)
// Poll for status updates (every 10 seconds when not terminal)
const { data: pollData } = useChallengePoll(
providerId,
challenge.id,
!isTerminal,
10000
)
const { verifyMutation, deleteMutation } = useManualChallengeMutations()
// Current status from poll data or initial challenge
const currentStatus: ChallengeStatus = pollData?.status || challenge.status
const dnsPropagated = pollData?.dns_propagated ?? challenge.dns_propagated
const lastCheckAt = pollData?.last_check_at ?? challenge.last_check_at
// Update countdown timer
useEffect(() => {
if (isTerminal) return
const interval = setInterval(() => {
const remaining = getTimeRemainingSeconds(challenge.expires_at)
setTimeRemaining(remaining)
setProgress(calculateProgress(challenge.expires_at, challenge.created_at))
// Auto-expire if time runs out
if (remaining <= 0) {
clearInterval(interval)
}
}, 1000)
return () => clearInterval(interval)
}, [challenge.expires_at, challenge.created_at, isTerminal])
// Announce status changes to screen readers
useEffect(() => {
if (currentStatus !== previousStatusRef.current) {
previousStatusRef.current = currentStatus
const statusLabel = t(STATUS_CONFIG[currentStatus].labelKey)
// Announce the status change for screen readers
if (statusAnnouncerRef.current) {
statusAnnouncerRef.current.textContent = t('dnsProvider.manual.statusChanged', {
status: statusLabel,
})
}
// Show toast for terminal states
if (currentStatus === 'verified') {
toast.success(t('dnsProvider.manual.verifySuccess'))
onComplete(true)
} else if (currentStatus === 'expired') {
toast.error(t('dnsProvider.manual.challengeExpired'))
onComplete(false)
} else if (currentStatus === 'failed') {
toast.error(pollData?.error_message || t('dnsProvider.manual.verifyFailed'))
onComplete(false)
}
}
}, [currentStatus, pollData?.error_message, onComplete, t])
// Copy to clipboard handler
const handleCopy = useCallback(
async (field: 'name' | 'value', text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopiedField(field)
toast.success(t('dnsProvider.manual.copied'))
// Reset copied state after 2 seconds
setTimeout(() => setCopiedField(null), 2000)
} catch {
toast.error(t('dnsProvider.manual.copyFailed'))
}
},
[t]
)
// Verify challenge handler
const handleVerify = useCallback(async () => {
try {
const result = await verifyMutation.mutateAsync({
providerId,
challengeId: challenge.id,
})
if (result.success) {
toast.success(t('dnsProvider.manual.verifySuccess'))
} else if (!result.dns_found) {
toast.warning(t('dnsProvider.manual.dnsNotFound'))
}
} catch (error: any) {
toast.error(error.response?.data?.message || t('dnsProvider.manual.verifyFailed'))
}
}, [verifyMutation, providerId, challenge.id, t])
// Cancel challenge handler
const handleCancel = useCallback(async () => {
try {
await deleteMutation.mutateAsync({
providerId,
challengeId: challenge.id,
})
toast.info(t('dnsProvider.manual.challengeCancelled'))
onCancel()
} catch (error: any) {
toast.error(error.response?.data?.message || t('dnsProvider.manual.cancelFailed'))
}
}, [deleteMutation, providerId, challenge.id, onCancel, t])
// Get status display properties
const statusConfig = STATUS_CONFIG[currentStatus]
const StatusIcon = statusConfig.icon
// Format last check time
const getLastCheckText = (): string => {
if (!lastCheckAt) return ''
const seconds = Math.floor((Date.now() - new Date(lastCheckAt).getTime()) / 1000)
if (seconds < 60) return t('dnsProvider.manual.lastCheckSecondsAgo', { seconds })
const minutes = Math.floor(seconds / 60)
return t('dnsProvider.manual.lastCheckMinutesAgo', { minutes })
}
return (
<Card className="w-full max-w-2xl">
{/* Screen reader announcer for status changes */}
<div
ref={statusAnnouncerRef}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span aria-hidden="true">🔐</span>
{t('dnsProvider.manual.title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Instructions */}
<p className="text-content-secondary">
{t('dnsProvider.manual.instructions', { domain: challenge.fqdn.replace('_acme-challenge.', '') })}
</p>
{/* DNS Record Details */}
<div
className="border border-border rounded-lg overflow-hidden"
role="region"
aria-labelledby="dns-record-heading"
>
<div className="bg-surface-subtle px-4 py-2 border-b border-border">
<h3 id="dns-record-heading" className="text-sm font-medium flex items-center gap-2">
<span aria-hidden="true">📋</span>
{t('dnsProvider.manual.createRecord')}
</h3>
</div>
{/* Record Name */}
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<label
htmlFor="record-name"
className="block text-xs font-medium text-content-muted mb-1"
>
{t('dnsProvider.manual.recordName')}
</label>
<code
id="record-name"
className="block text-sm font-mono text-content-primary truncate"
title={challenge.fqdn}
>
{challenge.fqdn}
</code>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleCopy('name', challenge.fqdn)}
aria-label={t('dnsProvider.manual.copyRecordName')}
className="flex-shrink-0"
>
{copiedField === 'name' ? (
<Check className="h-4 w-4 text-success" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
<span className="sr-only">
{copiedField === 'name' ? t('dnsProvider.manual.copied') : t('dnsProvider.manual.copy')}
</span>
</Button>
</div>
</div>
{/* Record Value */}
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<label
htmlFor="record-value"
className="block text-xs font-medium text-content-muted mb-1"
>
{t('dnsProvider.manual.recordValue')}
</label>
<code
id="record-value"
className="block text-sm font-mono text-content-primary truncate"
title={challenge.value}
>
{challenge.value}
</code>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleCopy('value', challenge.value)}
aria-label={t('dnsProvider.manual.copyRecordValue')}
className="flex-shrink-0"
>
{copiedField === 'value' ? (
<Check className="h-4 w-4 text-success" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
<span className="sr-only">
{copiedField === 'value' ? t('dnsProvider.manual.copied') : t('dnsProvider.manual.copy')}
</span>
</Button>
</div>
</div>
{/* TTL */}
<div className="px-4 py-3 bg-surface-subtle/50">
<span className="text-sm text-content-muted">
{t('dnsProvider.manual.ttl')}: {challenge.ttl} {t('dnsProvider.manual.seconds')} ({Math.floor(challenge.ttl / 60)} {t('dnsProvider.manual.minutes')})
</span>
</div>
</div>
{/* Progress Section */}
{!isTerminal && (
<div className="space-y-2" role="region" aria-labelledby="time-remaining-heading">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-content-muted" aria-hidden="true" />
<span id="time-remaining-heading" className="text-sm font-medium">
{t('dnsProvider.manual.timeRemaining')}: {formatTimeRemaining(timeRemaining)}
</span>
</div>
<span
className="text-sm text-content-muted tabular-nums"
aria-label={t('dnsProvider.manual.progressPercent', { percent: progress })}
>
{progress}%
</span>
</div>
<Progress
value={progress}
variant={progress < 25 ? 'error' : progress < 50 ? 'warning' : 'default'}
aria-label={t('dnsProvider.manual.challengeProgress')}
/>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-3">
<Button
variant="secondary"
onClick={handleVerify}
disabled={isTerminal || verifyMutation.isPending}
isLoading={verifyMutation.isPending}
leftIcon={RefreshCw}
aria-describedby="check-dns-description"
>
{t('dnsProvider.manual.checkDnsNow')}
</Button>
<span id="check-dns-description" className="sr-only">
{t('dnsProvider.manual.checkDnsDescription')}
</span>
<Button
variant="primary"
onClick={handleVerify}
disabled={isTerminal || verifyMutation.isPending}
isLoading={verifyMutation.isPending}
leftIcon={CheckCircle2}
aria-describedby="verify-description"
>
{t('dnsProvider.manual.verifyButton')}
</Button>
<span id="verify-description" className="sr-only">
{t('dnsProvider.manual.verifyDescription')}
</span>
</div>
{/* Status Indicator */}
<Alert
variant={
currentStatus === 'verified'
? 'success'
: currentStatus === 'failed' || currentStatus === 'expired'
? 'error'
: 'info'
}
>
<div className="flex items-start gap-3">
<StatusIcon
className={`h-5 w-5 flex-shrink-0 ${statusConfig.colorClass} ${
currentStatus === 'verifying' ? 'animate-spin' : ''
}`}
aria-hidden="true"
/>
<div className="flex-1 min-w-0">
<p className="font-medium">
{t(`dnsProvider.manual.statusMessage.${currentStatus}`, {
defaultValue: t(statusConfig.labelKey),
})}
</p>
{lastCheckAt && !isTerminal && (
<p className="text-sm text-content-muted mt-1">
{t('dnsProvider.manual.lastCheck')}: {getLastCheckText()}
</p>
)}
{!dnsPropagated && !isTerminal && (
<p className="text-sm text-content-muted mt-1 flex items-center gap-1">
<Info className="h-3 w-3" aria-hidden="true" />
{t('dnsProvider.manual.notPropagated')}
</p>
)}
{pollData?.error_message && (
<p className="text-sm text-error mt-1">{pollData.error_message}</p>
)}
</div>
</div>
</Alert>
{/* Cancel Button */}
{!isTerminal && (
<div className="flex justify-end pt-2 border-t border-border">
<Button
variant="ghost"
onClick={handleCancel}
disabled={deleteMutation.isPending}
isLoading={deleteMutation.isPending}
>
{t('dnsProvider.manual.cancelChallenge')}
</Button>
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1 @@
export { default as ManualDNSChallenge } from './ManualDNSChallenge'