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

View File

@@ -0,0 +1,712 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ManualDNSChallenge from '../dns-providers/ManualDNSChallenge'
import { useChallengePoll, useManualChallengeMutations } from '../../hooks/useManualChallenge'
import type { ManualChallenge } from '../../api/manualChallenge'
import { toast } from '../../utils/toast'
// Mock dependencies
vi.mock('../../hooks/useManualChallenge')
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}))
// Mock clipboard API using vi.stubGlobal
const mockWriteText = vi.fn()
vi.stubGlobal('navigator', {
...navigator,
clipboard: {
writeText: mockWriteText,
},
})
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'dnsProvider.manual.title': 'Manual DNS Challenge',
'dnsProvider.manual.instructions': `To obtain a certificate for ${options?.domain || 'example.com'}, create the following TXT record at your DNS provider:`,
'dnsProvider.manual.createRecord': 'Create this TXT record at your DNS provider',
'dnsProvider.manual.recordName': 'Record Name',
'dnsProvider.manual.recordValue': 'Record Value',
'dnsProvider.manual.ttl': 'TTL',
'dnsProvider.manual.seconds': 'seconds',
'dnsProvider.manual.minutes': 'minutes',
'dnsProvider.manual.timeRemaining': 'Time remaining',
'dnsProvider.manual.progressPercent': `${options?.percent || 0}% time remaining`,
'dnsProvider.manual.challengeProgress': 'Challenge timeout progress',
'dnsProvider.manual.copy': 'Copy',
'dnsProvider.manual.copied': 'Copied!',
'dnsProvider.manual.copyFailed': 'Failed to copy to clipboard',
'dnsProvider.manual.copyRecordName': 'Copy record name to clipboard',
'dnsProvider.manual.copyRecordValue': 'Copy record value to clipboard',
'dnsProvider.manual.checkDnsNow': 'Check DNS Now',
'dnsProvider.manual.checkDnsDescription': 'Immediately check if the DNS record has propagated',
'dnsProvider.manual.verifyButton': "I've Created the Record - Verify",
'dnsProvider.manual.verifyDescription': 'Verify that the DNS record exists',
'dnsProvider.manual.cancelChallenge': 'Cancel Challenge',
'dnsProvider.manual.lastCheck': 'Last checked',
'dnsProvider.manual.lastCheckSecondsAgo': `${options?.seconds || 0} seconds ago`,
'dnsProvider.manual.lastCheckMinutesAgo': `${options?.minutes || 0} minutes ago`,
'dnsProvider.manual.notPropagated': 'DNS record not yet propagated',
'dnsProvider.manual.dnsNotFound': 'DNS record not found',
'dnsProvider.manual.verifySuccess': 'DNS challenge verified successfully!',
'dnsProvider.manual.verifyFailed': 'DNS verification failed',
'dnsProvider.manual.challengeExpired': 'Challenge expired',
'dnsProvider.manual.challengeCancelled': 'Challenge cancelled',
'dnsProvider.manual.cancelFailed': 'Failed to cancel challenge',
'dnsProvider.manual.statusChanged': `Challenge status changed to ${options?.status || ''}`,
'dnsProvider.manual.status.created': 'Created',
'dnsProvider.manual.status.pending': 'Pending',
'dnsProvider.manual.status.verifying': 'Verifying...',
'dnsProvider.manual.status.verified': 'Verified',
'dnsProvider.manual.status.expired': 'Expired',
'dnsProvider.manual.status.failed': 'Failed',
'dnsProvider.manual.statusMessage.pending': 'Waiting for DNS propagation...',
'dnsProvider.manual.statusMessage.verified': 'DNS challenge verified successfully!',
'dnsProvider.manual.statusMessage.expired': 'Challenge has expired.',
'dnsProvider.manual.statusMessage.failed': 'DNS verification failed.',
}
return translations[key] || key
},
}),
}))
const mockChallenge: ManualChallenge = {
id: 'test-challenge-uuid',
status: 'pending',
fqdn: '_acme-challenge.example.com',
value: 'gZrH7wL9t3kM2nP4qX5yR8sT0uV1wZ2aB3cD4eF5gH6iJ7',
ttl: 300,
created_at: new Date(Date.now() - 2 * 60 * 1000).toISOString(), // 2 minutes ago
expires_at: new Date(Date.now() + 8 * 60 * 1000).toISOString(), // 8 minutes from now
last_check_at: new Date(Date.now() - 10 * 1000).toISOString(), // 10 seconds ago
dns_propagated: false,
}
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderComponent = (
challenge: ManualChallenge = mockChallenge,
onComplete = vi.fn(),
onCancel = vi.fn()
) => {
const queryClient = createQueryClient()
return {
...render(
<QueryClientProvider client={queryClient}>
<ManualDNSChallenge
providerId={1}
challenge={challenge}
onComplete={onComplete}
onCancel={onCancel}
/>
</QueryClientProvider>
),
onComplete,
onCancel,
}
}
describe('ManualDNSChallenge', () => {
let mockVerifyMutation: ReturnType<typeof vi.fn>
let mockDeleteMutation: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
mockWriteText.mockResolvedValue(undefined)
mockVerifyMutation = vi.fn()
mockDeleteMutation = vi.fn()
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'pending',
dns_propagated: false,
time_remaining_seconds: 480,
last_check_at: new Date(Date.now() - 10 * 1000).toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
vi.mocked(useManualChallengeMutations).mockReturnValue({
verifyMutation: {
mutateAsync: mockVerifyMutation,
isPending: false,
},
deleteMutation: {
mutateAsync: mockDeleteMutation,
isPending: false,
},
createMutation: {
mutateAsync: vi.fn(),
isPending: false,
},
} as any)
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
it('renders the challenge title', () => {
renderComponent()
expect(screen.getByText('Manual DNS Challenge')).toBeInTheDocument()
})
it('displays the FQDN record name', () => {
renderComponent()
expect(screen.getByText('_acme-challenge.example.com')).toBeInTheDocument()
})
it('displays the challenge value', () => {
renderComponent()
expect(
screen.getByText('gZrH7wL9t3kM2nP4qX5yR8sT0uV1wZ2aB3cD4eF5gH6iJ7')
).toBeInTheDocument()
})
it('displays TTL information', () => {
renderComponent()
expect(screen.getByText(/300/)).toBeInTheDocument()
expect(screen.getByText(/5 minutes/)).toBeInTheDocument()
})
it('renders copy buttons with aria labels', () => {
renderComponent()
expect(
screen.getByRole('button', { name: /copy record name/i })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /copy record value/i })
).toBeInTheDocument()
})
it('renders verify and check DNS buttons', () => {
renderComponent()
expect(screen.getByText('Check DNS Now')).toBeInTheDocument()
expect(screen.getByText("I've Created the Record - Verify")).toBeInTheDocument()
})
it('renders cancel button when not in terminal state', () => {
renderComponent()
expect(screen.getByText('Cancel Challenge')).toBeInTheDocument()
})
})
describe('Progress and Countdown', () => {
it('displays time remaining', () => {
renderComponent()
expect(screen.getByText(/Time remaining/i)).toBeInTheDocument()
})
it('displays progress bar', () => {
renderComponent()
expect(
screen.getByRole('progressbar', { name: /challenge.*progress/i })
).toBeInTheDocument()
})
it('updates countdown every second', async () => {
renderComponent()
// Get initial time display
const timeElement = screen.getByText(/Time remaining/i)
expect(timeElement).toBeInTheDocument()
// Advance timer by 1 second
await act(async () => {
vi.advanceTimersByTime(1000)
})
// Time should have updated (countdown decreased)
expect(timeElement).toBeInTheDocument()
})
})
describe('Copy Functionality', () => {
// Note: These tests verify the clipboard copy functionality.
// Due to jsdom limitations with navigator.clipboard mocking, we test
// the UI state changes instead of verifying the actual clipboard API calls.
// The component correctly shows "Copied!" state after clicking, which
// indicates the async copy handler completed successfully.
beforeEach(() => {
vi.useRealTimers()
})
afterEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
it('shows copied state after clicking copy record name button', async () => {
const user = userEvent.setup()
renderComponent()
const copyNameButton = screen.getByRole('button', { name: /copy record name/i })
await user.click(copyNameButton)
// The button should show the "Copied!" state after successful copy
await waitFor(() => {
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
})
it('shows copied state after clicking copy record value button', async () => {
const user = userEvent.setup()
renderComponent()
const copyValueButton = screen.getByRole('button', { name: /copy record value/i })
await user.click(copyValueButton)
// The button should show the "Copied!" state after successful copy
await waitFor(() => {
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
})
it('copy buttons are accessible and clickable', () => {
renderComponent()
const copyNameButton = screen.getByRole('button', { name: /copy record name/i })
const copyValueButton = screen.getByRole('button', { name: /copy record value/i })
expect(copyNameButton).toBeEnabled()
expect(copyValueButton).toBeEnabled()
expect(copyNameButton).toHaveAttribute('aria-label')
expect(copyValueButton).toHaveAttribute('aria-label')
})
})
describe('Verification', () => {
it('calls verify mutation when verify button is clicked', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: true, dns_found: true, message: 'OK' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(mockVerifyMutation).toHaveBeenCalledWith({
providerId: 1,
challengeId: 'test-challenge-uuid',
})
})
it('calls verify mutation when Check DNS Now is clicked', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: false, dns_found: false, message: '' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const checkButton = screen.getByText('Check DNS Now')
await user.click(checkButton)
expect(mockVerifyMutation).toHaveBeenCalled()
})
it('shows success toast on successful verification', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: true, dns_found: true, message: 'OK' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(toast.success).toHaveBeenCalledWith('DNS challenge verified successfully!')
})
it('shows warning toast when DNS not found', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: false, dns_found: false, message: '' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(toast.warning).toHaveBeenCalledWith('DNS record not found')
})
it('shows error toast on verification failure', async () => {
mockVerifyMutation.mockRejectedValueOnce({
response: { data: { message: 'Server error' } },
})
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(toast.error).toHaveBeenCalledWith('Server error')
})
})
describe('Cancellation', () => {
it('calls delete mutation and onCancel when cancelled', async () => {
mockDeleteMutation.mockResolvedValueOnce(undefined)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const { onCancel } = renderComponent()
const cancelButton = screen.getByText('Cancel Challenge')
await user.click(cancelButton)
expect(mockDeleteMutation).toHaveBeenCalledWith({
providerId: 1,
challengeId: 'test-challenge-uuid',
})
expect(onCancel).toHaveBeenCalled()
expect(toast.info).toHaveBeenCalledWith('Challenge cancelled')
})
it('shows error toast when cancellation fails', async () => {
mockDeleteMutation.mockRejectedValueOnce({
response: { data: { message: 'Cannot cancel' } },
})
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const cancelButton = screen.getByText('Cancel Challenge')
await user.click(cancelButton)
expect(toast.error).toHaveBeenCalledWith('Cannot cancel')
})
})
describe('Terminal States', () => {
it('hides cancel button when challenge is verified', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
const verifiedChallenge: ManualChallenge = {
...mockChallenge,
status: 'verified',
dns_propagated: true,
}
renderComponent(verifiedChallenge)
expect(screen.queryByText('Cancel Challenge')).not.toBeInTheDocument()
})
it('hides progress bar when challenge is expired', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'expired',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
const expiredChallenge: ManualChallenge = {
...mockChallenge,
status: 'expired',
}
renderComponent(expiredChallenge)
expect(
screen.queryByRole('progressbar', { name: /challenge.*progress/i })
).not.toBeInTheDocument()
})
it('disables verify buttons when challenge is failed', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'failed',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
error_message: 'ACME validation failed',
},
isLoading: false,
isError: false,
error: null,
} as any)
const failedChallenge: ManualChallenge = {
...mockChallenge,
status: 'failed',
}
renderComponent(failedChallenge)
expect(screen.getByText('Check DNS Now').closest('button')).toBeDisabled()
expect(
screen.getByText("I've Created the Record - Verify").closest('button')
).toBeDisabled()
})
it('calls onComplete with true when status changes to verified', async () => {
const { onComplete, rerender } = renderComponent()
// Update poll data to verified
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
// Re-render to trigger effect
const verifiedChallenge: ManualChallenge = {
...mockChallenge,
status: 'verified',
dns_propagated: true,
}
const queryClient = createQueryClient()
rerender(
<QueryClientProvider client={queryClient}>
<ManualDNSChallenge
providerId={1}
challenge={verifiedChallenge}
onComplete={onComplete}
onCancel={vi.fn()}
/>
</QueryClientProvider>
)
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(true)
})
})
it('calls onComplete with false when status changes to expired', async () => {
const { onComplete, rerender } = renderComponent()
// Update poll data to expired
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'expired',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
const expiredChallenge: ManualChallenge = {
...mockChallenge,
status: 'expired',
}
const queryClient = createQueryClient()
rerender(
<QueryClientProvider client={queryClient}>
<ManualDNSChallenge
providerId={1}
challenge={expiredChallenge}
onComplete={onComplete}
onCancel={vi.fn()}
/>
</QueryClientProvider>
)
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(false)
})
})
})
describe('Accessibility', () => {
it('has proper ARIA labels for copy buttons', () => {
renderComponent()
expect(
screen.getByRole('button', { name: /copy record name to clipboard/i })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /copy record value to clipboard/i })
).toBeInTheDocument()
})
it('has screen reader announcer for status changes', () => {
renderComponent()
const announcer = document.querySelector('[role="status"][aria-live="polite"]')
expect(announcer).toBeInTheDocument()
})
it('has proper labels for form fields', () => {
renderComponent()
expect(screen.getByText('Record Name')).toBeInTheDocument()
expect(screen.getByText('Record Value')).toBeInTheDocument()
})
it('progress bar has accessible label', () => {
renderComponent()
const progressBar = screen.getByRole('progressbar')
expect(progressBar).toHaveAttribute('aria-label')
})
it('buttons have aria-describedby for additional context', () => {
renderComponent()
const checkDnsButton = screen.getByText('Check DNS Now').closest('button')
expect(checkDnsButton).toHaveAttribute('aria-describedby')
})
it('uses semantic heading structure', () => {
renderComponent()
// Card title should exist
expect(screen.getByText('Manual DNS Challenge')).toBeInTheDocument()
// Section heading for DNS record
expect(screen.getByText('Create this TXT record at your DNS provider')).toBeInTheDocument()
})
})
describe('Polling Behavior', () => {
it('enables polling when challenge is pending', () => {
renderComponent()
expect(useChallengePoll).toHaveBeenCalledWith(1, 'test-challenge-uuid', true, 10000)
})
it('disables polling when challenge is in terminal state', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
const verifiedChallenge: ManualChallenge = {
...mockChallenge,
status: 'verified',
}
renderComponent(verifiedChallenge)
// The component should pass enabled=false for terminal states
expect(useChallengePoll).toHaveBeenCalledWith(1, 'test-challenge-uuid', false, 10000)
})
})
describe('Loading States', () => {
it('shows loading state on verify button while verifying', () => {
vi.mocked(useManualChallengeMutations).mockReturnValue({
verifyMutation: {
mutateAsync: mockVerifyMutation,
isPending: true,
},
deleteMutation: {
mutateAsync: mockDeleteMutation,
isPending: false,
},
createMutation: {
mutateAsync: vi.fn(),
isPending: false,
},
} as any)
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify").closest('button')
expect(verifyButton).toBeDisabled()
})
it('shows loading state on cancel button while cancelling', () => {
vi.mocked(useManualChallengeMutations).mockReturnValue({
verifyMutation: {
mutateAsync: mockVerifyMutation,
isPending: false,
},
deleteMutation: {
mutateAsync: mockDeleteMutation,
isPending: true,
},
createMutation: {
mutateAsync: vi.fn(),
isPending: false,
},
} as any)
renderComponent()
const cancelButton = screen.getByText('Cancel Challenge').closest('button')
expect(cancelButton).toBeDisabled()
})
})
describe('Error Messages', () => {
it('displays error message from poll response', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'failed',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
error_message: 'ACME server rejected the challenge',
},
isLoading: false,
isError: false,
error: null,
} as any)
const failedChallenge: ManualChallenge = {
...mockChallenge,
status: 'failed',
error_message: 'ACME server rejected the challenge',
}
renderComponent(failedChallenge)
expect(screen.getByText('ACME server rejected the challenge')).toBeInTheDocument()
})
})
describe('Last Check Display', () => {
it('shows last check time when available', () => {
renderComponent()
expect(screen.getByText(/Last checked/i)).toBeInTheDocument()
})
it('shows propagation status when not propagated', () => {
renderComponent()
expect(screen.getByText(/not yet propagated/i)).toBeInTheDocument()
})
})
})

View File

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

View File

@@ -0,0 +1 @@
export { default as ManualDNSChallenge } from './ManualDNSChallenge'