import { useCallback, useEffect, useRef, useState } from 'react'; import { KeyRound, Loader2, ShieldAlert } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { readableError } from './errors'; /** * Entering a DNS provider's API credential. * * ── The fields are NPM's, not ours ── * * `GET /nginx/certificates/dns-providers` returns a `credentials` TEMPLATE per provider, carrying that * provider's own field names — one token for Cloudflare, a key and a secret for others. So the form is * GENERATED rather than written per provider, and it stays right for providers nobody here has heard of. * * When the template cannot be fetched — no NPM connection yet, or a provider it does not support — the * fallback is a single box in `key = value` form, which is the shape certbot wants anyway. Worse to use * and not wrong, which is the correct direction for a fallback. * * ── Narrow versus broad, and why it is not read versus write ── * * Both can issue certificates, because a DNS-01 challenge WRITES a TXT record — there is no read-only * credential that works, and offering one would produce a setup that looks configured and fails at the * first renewal, sixty days later. What differs is what else the key can do if it leaks. */ type NpmProvider = { id: string; name: string; credentials: string }; /** * Pull field names out of certbot's ini template. * * `dns_cloudflare_api_token = ` → `dns_cloudflare_api_token`. Anything that is not a `key = value` * line is skipped rather than guessed at. */ const fieldsFromTemplate = (template: string): string[] => template .split('\n') .map((line) => line.split('=')[0]?.trim()) .filter((key): key is string => !!key && /^[a-z0-9_]+$/i.test(key)); /** `dns_cloudflare_api_token` → `Api token`. The prefix is noise once the provider is named above. */ const labelFor = (field: string, provider: string): string => { const stripped = field.replace(new RegExp(`^dns_${provider}_`, 'i'), '').replace(/^dns_/, ''); const words = stripped.replace(/_/g, ' ').trim(); return words.charAt(0).toUpperCase() + words.slice(1); }; type Props = { provider: string; hasExisting: boolean; onClose: () => void; onSaved: () => void; }; export const CredentialDialog = ({ provider, hasExisting, onClose, onSaved }: Props) => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [fields, setFields] = useState(null); const [values, setValues] = useState>({}); const [raw, setRaw] = useState(''); const [scope, setScope] = useState<'narrow' | 'broad'>('narrow'); const [label, setLabel] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [forbidden, setForbidden] = useState(false); const loadTemplate = useCallback(async () => { try { const list = await clientRef.current.get( '/proxy-manager/npm/nginx/certificates/dns-providers', ); const match = list.find((entry) => entry.id.toLowerCase() === provider.toLowerCase()); setFields(match ? fieldsFromTemplate(match.credentials) : []); } catch { // No connection, or NPM does not know this provider. The free-form box covers both. setFields([]); } }, [provider]); useEffect(() => { void loadTemplate(); }, [loadTemplate]); const save = async () => { setSaving(true); setError(null); try { const entered = fields && fields.length > 0 ? values : Object.fromEntries( raw .split('\n') .map((line) => { const index = line.indexOf('='); return index === -1 ? null : [line.slice(0, index).trim(), line.slice(index + 1).trim()]; }) .filter((pair): pair is [string, string] => !!pair && !!pair[0] && !!pair[1]), ); await clientRef.current.put(`/proxy-manager/_config/dns-providers/${encodeURIComponent(provider)}`, { fields: entered, scope, label: label.trim() || undefined, }); onSaved(); } catch (caught) { if ((caught as { status?: number })?.status === 403) setForbidden(true); else setError(readableError(caught, 'Could not save that.')); } finally { setSaving(false); } }; const generated = fields && fields.length > 0; const filled = generated ? fields.some((f) => values[f]?.trim()) : raw.trim().length > 0; return (
ev.stopPropagation()} >
{hasExisting ? 'Replace' : 'Add'} the {provider} API key
{forbidden ? ( <>

Only the server owner can store a DNS credential.

It can rewrite every record for a domain, which is more than the proxy login already kept here.

) : ( <> {fields === null ? (

Looking up what {provider} needs…

) : generated ? ( fields.map((field) => ( )) ) : (