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:
712
frontend/src/components/__tests__/ManualDNSChallenge.test.tsx
Normal file
712
frontend/src/components/__tests__/ManualDNSChallenge.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
479
frontend/src/components/dns-providers/ManualDNSChallenge.tsx
Normal file
479
frontend/src/components/dns-providers/ManualDNSChallenge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/src/components/dns-providers/index.ts
Normal file
1
frontend/src/components/dns-providers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ManualDNSChallenge } from './ManualDNSChallenge'
|
||||
Reference in New Issue
Block a user