fix: enhance DNSProviders page to improve manual challenge handling and visibility of provider cards
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Plus, Cloud } from 'lucide-react'
|
||||
import { Button, Alert, EmptyState, Skeleton } from '../components/ui'
|
||||
@@ -19,37 +19,43 @@ export default function DNSProviders() {
|
||||
const [testingProviderId, setTestingProviderId] = useState<number | null>(null)
|
||||
const [manualChallenge, setManualChallenge] = useState<ManualChallenge | null>(null)
|
||||
const [activeManualProviderId, setActiveManualProviderId] = useState<number | null>(null)
|
||||
const [isManualChallengeOpen, setIsManualChallengeOpen] = useState(false)
|
||||
|
||||
const manualProviderId = providers.find((provider) => provider.provider_type === 'manual')?.id ?? 1
|
||||
const manualProviderId = providers.find((provider) => provider.provider_type === 'manual')?.id ?? null
|
||||
|
||||
const loadManualChallenge = useCallback(async (providerId: number) => {
|
||||
const loadManualChallenge = useCallback(async (providerId: number): Promise<boolean> => {
|
||||
try {
|
||||
const challenge = await getChallenge(providerId, 'active')
|
||||
setManualChallenge(challenge)
|
||||
setActiveManualProviderId(providerId)
|
||||
return true
|
||||
} catch {
|
||||
const now = new Date()
|
||||
const fallbackChallenge: ManualChallenge = {
|
||||
id: 'active',
|
||||
status: 'pending',
|
||||
fqdn: '_acme-challenge.example.com',
|
||||
value: 'mock-challenge-token-value-abc123',
|
||||
ttl: 300,
|
||||
created_at: now.toISOString(),
|
||||
expires_at: new Date(now.getTime() + 10 * 60 * 1000).toISOString(),
|
||||
dns_propagated: false,
|
||||
}
|
||||
setManualChallenge(fallbackChallenge)
|
||||
setManualChallenge(null)
|
||||
setActiveManualProviderId(providerId)
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
void loadManualChallenge(manualProviderId)
|
||||
}, [isLoading, loadManualChallenge, manualProviderId])
|
||||
const manualChallengeProviderId = activeManualProviderId ?? manualProviderId
|
||||
const showManualChallenge =
|
||||
isManualChallengeOpen && Boolean(manualChallenge) && manualChallengeProviderId !== null
|
||||
|
||||
const showManualChallenge = Boolean(manualChallenge)
|
||||
const handleManualChallengeClick = async () => {
|
||||
if (manualProviderId === null) {
|
||||
toast.error(t('dnsProviders.noProviders'))
|
||||
return
|
||||
}
|
||||
|
||||
const hasChallenge = await loadManualChallenge(manualProviderId)
|
||||
|
||||
if (!hasChallenge) {
|
||||
toast.error(t('dnsProvider.manual.challengeNotFound'))
|
||||
setIsManualChallengeOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsManualChallengeOpen(true)
|
||||
}
|
||||
|
||||
const handleAddProvider = () => {
|
||||
setEditingProvider(null)
|
||||
@@ -124,20 +130,29 @@ export default function DNSProviders() {
|
||||
</Alert>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" onClick={() => void loadManualChallenge(manualProviderId)}>
|
||||
<Button variant="secondary" onClick={() => void handleManualChallengeClick()}>
|
||||
{t('dnsProvider.manual.title')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showManualChallenge && manualChallenge && (
|
||||
{showManualChallenge && manualChallenge && manualChallengeProviderId !== null && (
|
||||
<ManualDNSChallenge
|
||||
providerId={activeManualProviderId ?? manualProviderId}
|
||||
providerId={manualChallengeProviderId}
|
||||
challenge={manualChallenge}
|
||||
onComplete={() => {
|
||||
void loadManualChallenge(activeManualProviderId ?? manualProviderId)
|
||||
const providerId = activeManualProviderId ?? manualProviderId
|
||||
if (providerId === null) {
|
||||
setIsManualChallengeOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
void loadManualChallenge(providerId).then((hasChallenge) => {
|
||||
setIsManualChallengeOpen(hasChallenge)
|
||||
})
|
||||
}}
|
||||
onCancel={() => {
|
||||
setManualChallenge(null)
|
||||
setIsManualChallengeOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import DNSProviders from '../DNSProviders'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import { useDNSProviders, useDNSProviderMutations, type DNSProvider } from '../../hooks/useDNSProviders'
|
||||
import { getChallenge } from '../../api/manualChallenge'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDNSProviders', () => ({
|
||||
useDNSProviders: vi.fn(),
|
||||
useDNSProviderMutations: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/manualChallenge', () => ({
|
||||
getChallenge: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../components/ui', () => ({
|
||||
Button: ({ children, onClick, variant }: { children: ReactNode; onClick?: () => void; variant?: string }) => (
|
||||
<button type="button" data-variant={variant} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Alert: ({ children }: { children: ReactNode }) => <div role="alert">{children}</div>,
|
||||
EmptyState: ({ title }: { title: string }) => <div>{title}</div>,
|
||||
Skeleton: () => <div data-testid="skeleton" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../components/DNSProviderCard', () => ({
|
||||
default: ({ provider }: { provider: DNSProvider }) => (
|
||||
<article data-testid="provider-card">
|
||||
<h3>{provider.name}</h3>
|
||||
</article>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../components/DNSProviderForm', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../../components/dns-providers', () => ({
|
||||
ManualDNSChallenge: ({ challenge }: { challenge: { fqdn: string } }) => (
|
||||
<section data-testid="manual-dns-challenge">{challenge.fqdn}</section>
|
||||
),
|
||||
}))
|
||||
|
||||
const buildProvider = (overrides: Partial<DNSProvider> = {}): DNSProvider => ({
|
||||
id: 7,
|
||||
uuid: 'provider-uuid',
|
||||
name: 'Seeded Provider',
|
||||
provider_type: 'manual',
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
has_credentials: true,
|
||||
propagation_timeout: 60,
|
||||
polling_interval: 5,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
created_at: '2026-02-15T00:00:00Z',
|
||||
updated_at: '2026-02-15T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('DNSProviders page state behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.mocked(useDNSProviderMutations).mockReturnValue({
|
||||
deleteMutation: { mutateAsync: vi.fn() },
|
||||
testMutation: { mutateAsync: vi.fn() },
|
||||
createMutation: { mutateAsync: vi.fn() },
|
||||
updateMutation: { mutateAsync: vi.fn() },
|
||||
testCredentialsMutation: { mutateAsync: vi.fn() },
|
||||
} as unknown as ReturnType<typeof useDNSProviderMutations>)
|
||||
|
||||
vi.mocked(useDNSProviders).mockReturnValue({
|
||||
data: [buildProvider()],
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDNSProviders>)
|
||||
})
|
||||
|
||||
it('keeps provider cards visible by default without challenge fetch side effects', async () => {
|
||||
renderWithQueryClient(<DNSProviders />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Seeded Provider' })).toBeInTheDocument()
|
||||
expect(getChallenge).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not hide provider cards when manual challenge fetch fails', async () => {
|
||||
vi.mocked(getChallenge).mockRejectedValue(new Error('not found'))
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<DNSProviders />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dnsProvider.manual.title' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getChallenge).toHaveBeenCalledWith(7, 'active')
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Seeded Provider' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens manual challenge panel only after explicit action when fetch succeeds', async () => {
|
||||
vi.mocked(getChallenge).mockResolvedValue({
|
||||
id: 'active',
|
||||
status: 'pending',
|
||||
fqdn: '_acme-challenge.example.com',
|
||||
value: 'token',
|
||||
ttl: 300,
|
||||
created_at: '2026-02-15T00:00:00Z',
|
||||
expires_at: '2026-02-15T00:10:00Z',
|
||||
dns_propagated: false,
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<DNSProviders />)
|
||||
|
||||
expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dnsProvider.manual.title' }))
|
||||
|
||||
expect(await screen.findByTestId('manual-dns-challenge')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user