/* * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, ChevronDown, ClipboardCopy, ClipboardList, ExternalLink, Loader2, PlugZap, Radar, RefreshCw, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { Combobox } from '@/components/ui/combobox'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { WizardNote, WizardShell, type WizardStep } from '@/components/wizard/WizardShell'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { useSchemaStore } from '@/stores/schemaStore'; import { getAccountId, jmapGet, jmapSet } from '@/services/jmap/client'; import { friendlySetError } from '@/lib/jmapErrors'; import { humanize } from '@/lib/humanize'; import { cn } from '@/lib/utils'; import type { Field, Schema } from '@/types/schema'; import type { JmapSetError, JmapSetResponse } from '@/types/jmap'; import { ADVANCED_FIELDS, FEATURED, HIDDEN_VARIANTS, type ProviderGuide } from './providers'; import { DEFAULT_KINDS, RECORD_GROUPS } from './records'; import { parseZone, summarizeFailure, type RecordKind, type ZoneRecord } from './zone'; import { RESOLVER_NAME, type LiveState } from './liveCheck'; import { detectHosting, type DnsHosting } from './detect'; import { useRecordChecks } from './useRecordChecks'; import { CopyStep } from './CopyStep'; import { ProgressRing, StateIcon } from './parts'; export interface DomainInfo { id: string; name: string; dnsManagement?: { '@type': string; dnsServerId?: string; origin?: string | null; publishRecords?: Record; }; dnsZoneFile?: string; } interface ServerInfo { id: string; '@type': string; description?: string; } type Choice = { mode: 'existing'; id: string } | { mode: 'new'; variant: string }; const STEPS = (t: (k: string, d: string) => string): WizardStep[] => [ { id: 'provider', title: t('dnsWizard.stepProvider', 'Your DNS host') }, { id: 'connect', title: t('dnsWizard.stepConnect', 'Connect') }, { id: 'records', title: t('dnsWizard.stepRecords', 'Records') }, { id: 'review', title: t('dnsWizard.stepReview', 'Review') }, { id: 'live', title: t('dnsWizard.stepLive', 'Go live') }, ]; /** Field kinds the guided form can take; anything else sends the user to the full form. */ type Simple = 'string' | 'number' | 'boolean' | 'enum' | 'secret' | 'secretOptional' | 'secretText'; function simpleKind(field: Field): Simple | null { const ty = field.type as { type: string; format?: string; objectName?: string }; if (ty.type === 'string') return 'string'; if (ty.type === 'number' && ty.format !== 'duration') return 'number'; if (ty.type === 'boolean') return 'boolean'; if (ty.type === 'enum') return 'enum'; if (ty.type === 'object' && ty.objectName === 'x:SecretKey') return 'secret'; if (ty.type === 'object' && ty.objectName === 'x:SecretKeyOptional') return 'secretOptional'; if (ty.type === 'object' && ty.objectName === 'x:SecretText') return 'secretText'; return null; } function variantFields(schema: Schema, variant: string, guide?: ProviderGuide) { const sch = schema.schemas['x:DnsServer']; const schemaName = sch?.type === 'multiple' ? sch.variants.find((v) => v.name === variant)?.schemaName : undefined; const fields = schemaName ? schema.fields[schemaName] : undefined; const entries = Object.entries(fields?.properties ?? {}).filter( ([name]) => !ADVANCED_FIELDS.has(name) && !(guide?.variant === 'Cloudflare' && name === 'email'), ); return { defaults: fields?.defaults ?? {}, fields: entries.map(([name, field]) => ({ name, field, kind: simpleKind(field) })), }; } function isOptional(field: Field, kind: Simple | null): boolean { return kind === 'secretOptional' || Boolean((field.type as { nullable?: boolean }).nullable); } function payloadValue(kind: Simple, raw: string | boolean): unknown { switch (kind) { case 'secret': return { '@type': 'Value', secret: raw }; case 'secretOptional': return raw ? { '@type': 'Value', secret: raw } : { '@type': 'None' }; case 'secretText': return { '@type': 'Text', secret: raw }; case 'number': return Number(raw); default: return raw; } } function setError(res: JmapSetResponse | undefined, key: 'notCreated' | 'notUpdated' | 'notDestroyed'): string | null { const errs = res?.[key] as Record | null | undefined; const first = errs ? Object.values(errs)[0] : undefined; return first ? friendlySetError(first) : null; } export function ConnectDnsPage({ domainId }: { domainId: string }) { const { t } = useTranslation(); const navigate = useNavigate(); const schema = useSchemaStore((s) => s.schema); const viewToSection = useSchemaStore((s) => s.viewToSection); const steps = useMemo(() => STEPS(t), [t]); const copySteps = useMemo( () => [steps[0], { id: 'copy', title: t('dnsWizard.stepCopy', 'Add the records') }], [steps, t], ); const [step, setStep] = useState(0); const [domain, setDomain] = useState(null); const [servers, setServers] = useState([]); const [loadError, setLoadError] = useState(null); const [choice, setChoice] = useState(null); const [values, setValues] = useState>({}); const [kinds, setKinds] = useState>(new Set(DEFAULT_KINDS)); const [origin, setOrigin] = useState(''); const [showZone, setShowZone] = useState(false); const [busy, setBusy] = useState(false); const [error, setErrorText] = useState(null); const domainView = `/${viewToSection['x:Domain'] ?? 'Management'}/x:Domain/${domainId}`; const load = useCallback(async () => { const [domainRes] = await jmapGet( 'x:Domain', getAccountId('x:Domain'), [domainId], ['name', 'dnsManagement', 'dnsZoneFile'], ); const list = (domainRes?.[1] as { list?: DomainInfo[] })?.list ?? []; return list[0] ?? null; }, [domainId]); useEffect(() => { let cancelled = false; (async () => { try { const d = await load(); if (cancelled) return; if (!d) { setLoadError(t('dnsWizard.noDomain', 'That domain no longer exists.')); return; } setDomain(d); const current = d.dnsManagement; if (current?.['@type'] === 'Automatic') { if (current.dnsServerId) setChoice({ mode: 'existing', id: current.dnsServerId }); if (current.publishRecords) { setKinds( new Set(Object.keys(current.publishRecords).filter((k) => current.publishRecords?.[k]) as RecordKind[]), ); } setOrigin(current.origin ?? ''); } const [srvRes] = await jmapGet('x:DnsServer', getAccountId('x:DnsServer'), null, ['@type', 'description']); if (!cancelled) setServers(((srvRes?.[1] as { list?: ServerInfo[] })?.list ?? []) as ServerInfo[]); } catch (e) { if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e)); } })(); return () => { cancelled = true; }; }, [load, t]); const zone = useMemo(() => parseZone(domain?.dnsZoneFile), [domain]); // Where the domain's DNS lives, looked up once it's loaded. undefined while // looking, null when it isn't in public DNS (or the lookup failed). const [hosting, setHosting] = useState(undefined); const [showAll, setShowAll] = useState(false); const [path, setPath] = useState<'connect' | 'copy'>('connect'); const domainName = domain?.name; useEffect(() => { if (!domainName) return; const ctrl = new AbortController(); detectHosting(domainName, ctrl.signal) .then((h) => { setHosting(h); // A domain inside a bigger zone needs the zone named for the API. if (h && h.zone !== domainName.toLowerCase()) setOrigin((o) => o || h.zone); }) .catch(() => { if (!ctrl.signal.aborted) setHosting(null); }); return () => ctrl.abort(); }, [domainName]); const variants = useMemo(() => { const sch = schema?.schemas['x:DnsServer']; return sch?.type === 'multiple' ? sch.variants.filter((v) => !HIDDEN_VARIANTS.has(v.name)) : []; }, [schema]); const labelOf = (variant: string) => FEATURED.find((f) => f.variant === variant)?.name ?? variants.find((v) => v.name === variant)?.label ?? humanize(variant); const guide = choice?.mode === 'new' ? FEATURED.find((f) => f.variant === choice.variant) : undefined; const form = useMemo( () => (schema && choice?.mode === 'new' ? variantFields(schema, choice.variant, guide) : null), [schema, choice, guide], ); const unsupported = form?.fields.some((f) => f.kind === null && !isOptional(f.field, f.kind)) ?? false; const credsComplete = form?.fields.every(({ name, field, kind }) => { if (!kind || isOptional(field, kind) || kind === 'boolean' || kind === 'enum') return true; if (form.defaults[name] !== undefined) return true; const v = values[name]; return typeof v === 'string' && v.trim().length > 0; }) ?? false; // ── Finishing: create the provider if new, then switch the domain over. ── const [createdServerId, setCreatedServerId] = useState(null); const finish = async () => { if (!domain || !choice) return; setBusy(true); setErrorText(null); let serverId = choice.mode === 'existing' ? choice.id : null; let madeServer: string | null = null; try { if (choice.mode === 'new' && form) { const create: Record = { '@type': choice.variant, description: t('dnsWizard.providerDescription', '{{provider}} for {{domain}}', { provider: labelOf(choice.variant), domain: domain.name, }), }; for (const { name, kind } of form.fields) { if (!kind) continue; const raw = values[name]; if (raw === undefined || raw === '') { if (kind === 'secretOptional') create[name] = { '@type': 'None' }; continue; } create[name] = payloadValue(kind, raw); } const [res] = await jmapSet('x:DnsServer', getAccountId('x:DnsServer'), { create: { dns: create } }); const body = res?.[1] as unknown as JmapSetResponse | undefined; const id = (body?.created?.dns as { id?: string } | undefined)?.id; if (!id) throw new Error( setError(body, 'notCreated') ?? t('dnsWizard.createFailed', 'The provider could not be saved.'), ); serverId = id; madeServer = id; } const publishRecords = Object.fromEntries([...kinds].map((k) => [k, true])); const [res] = await jmapSet('x:Domain', getAccountId('x:Domain'), { update: { [domain.id]: { dnsManagement: { '@type': 'Automatic', dnsServerId: serverId, origin: origin.trim() || null, publishRecords, }, }, }, }); const body = res?.[1] as unknown as JmapSetResponse | undefined; const failed = setError(body, 'notUpdated'); if (failed || !body?.updated || !(domain.id in body.updated)) { throw new Error(failed ?? t('dnsWizard.updateFailed', 'The domain could not be switched to automatic DNS.')); } setCreatedServerId(madeServer); setStep(4); } catch (e) { // Failsafe: a provider made for this run is removed again, so a failed // attempt leaves nothing behind. if (madeServer) { await jmapSet('x:DnsServer', getAccountId('x:DnsServer'), { destroy: [madeServer] }).catch(() => undefined); } setErrorText(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; if (loadError) { return
{loadError}
; } if (!domain || !schema) return ; const selectedRecords = zone.filter((r) => kinds.has(r.kind)); const already = domain.dnsManagement?.['@type'] === 'Automatic'; const common = { icon: 'globe', title: t('dnsWizard.title', 'Publish DNS for {{domain}}', { domain: domain.name }), subtitle: t('dnsWizard.subtitle', 'Connect your DNS host once, and the server keeps these records right for you.'), steps, current: step, onCancel: () => navigate(domainView), }; // ── Step 1: where is DNS hosted? ── if (step === 0) { const pick = (c: Choice) => { setPath('connect'); setChoice(c); setValues({}); setStep(c.mode === 'existing' ? 2 : 1); }; const copyByHand = () => { setPath('copy'); setStep(1); }; const detected = hosting?.variant && variants.some((v) => v.name === hosting.variant) ? hosting.variant : null; const connectedSame = detected ? servers.filter((s) => s['@type'] === detected) : []; const hostName = hosting?.nameservers[0]?.split('.').slice(-2).join('.'); return (

{t( 'dnsWizard.whatBody', 'Your mail server writes its own DNS records through your DNS host’s API: mail routing, the records that prove your mail is yours, and the ones mail apps use to set themselves up.', )}

{t( 'dnsWizard.whatKeep', 'When keys rotate or settings change, it updates them again. Nothing is changed until the last step.', )}

} > {hosting === undefined ? (
{t('dnsWizard.detecting', 'Looking up where {{domain}}’s DNS is hosted…', { domain: domain.name })}
) : detected ? (

{t('dnsWizard.foundAt', '{{domain}}’s DNS is at {{provider}}', { domain: domain.name, provider: labelOf(detected), })}

{hosting && hosting.zone !== domain.name.toLowerCase() ? t('dnsWizard.foundZone', 'In the {{zone}} zone, served by {{ns}}.', { zone: hosting.zone, ns: hosting.nameservers.join(', '), }) : t('dnsWizard.foundNs', 'Served by {{ns}}.', { ns: hosting?.nameservers.join(', ') })}

{connectedSame.map((srv) => ( ))}
) : (

{hosting ? t('dnsWizard.unsupportedHost', '{{domain}}’s DNS is at {{host}}', { domain: domain.name, host: hostName, }) : t('dnsWizard.notFound', '{{domain}} isn’t in public DNS yet', { domain: domain.name })}

{hosting ? t( 'dnsWizard.unsupportedHint', 'The server can’t update that host for you, but adding the records by hand takes a few minutes, and this page checks each one as it goes live.', ) : t( 'dnsWizard.notFoundHint', 'Once it’s registered and pointed at a DNS host, you can add the records by hand or connect the host here.', )}

)} {hosting !== undefined && (
{!showAll ? ( ) : (
{servers.length > 0 && (
{servers.map((srv) => ( ))}
)}
variants.some((v) => v.name === f.variant)).map((f) => ({ value: f.variant, label: f.name, })), ...variants .filter((v) => !FEATURED.some((f) => f.variant === v.name)) .map((v) => ({ value: v.name, label: v.label })), ]} value="" onValueChange={(v) => v && pick({ mode: 'new', variant: v })} placeholder={t('dnsWizard.searchHosts', 'Search {{count}} DNS hosts…', { count: variants.length })} />
)}
)}
); } // ── By hand: copy each record, and watch it go live ── if (step === 1 && path === 'copy') { return ( setStep(0)} onDone={() => navigate(domainView)} /> ); } // ── Step 2: credentials ── if (step === 1 && choice?.mode === 'new' && form) { return ( setStep(0)} onNext={() => setStep(2)} canNext={credsComplete && !unsupported} aside={ <>

{t( 'dnsWizard.keepNarrowBody', 'Give the server a credential that can edit DNS for this domain and nothing else. If it ever leaked, that’s all it could touch.', )}

{t( 'dnsWizard.storedBody', 'It’s stored in the server’s settings and never shown again. You can replace or remove it on the DNS provider’s page.', )}

} >

{labelOf(choice.variant)}

{guide &&

{guide.blurb}

}
{guide && (
    {guide.steps.map((s, i) => (
  1. {i + 1} {s}
  2. ))} {guide.link && (
  3. {t('dnsWizard.openProvider', 'Open {{provider}}', { provider: guide.name })}
  4. )}
)} {unsupported ? (

{t( 'dnsWizard.unsupported', 'This provider needs settings the guided setup can’t take. Add it on the DNS providers page, then come back and choose it here.', )}

) : (
{form.fields.map(({ name, field, kind }) => kind ? ( setValues((prev) => ({ ...prev, [name]: v }))} /> ) : null, )}
)}
); } // ── Step 3: which records ── if (step === 2) { const toggle = (k: RecordKind, on: boolean) => setKinds((prev) => { const next = new Set(prev); if (on) next.add(k); else next.delete(k); return next; }); return ( setStep(choice?.mode === 'new' ? 1 : 0)} onNext={() => setStep(3)} canNext={kinds.size > 0} aside={

{t( 'dnsWizard.recordsNoteBody', 'Only the names listed here are written. Records the server doesn’t own, like your website or other TXT entries, are left as they are.', )}

} >
{RECORD_GROUPS.map((g) => (

{g.title}

{g.why}

{g.kinds.map(({ kind, label, caution }) => { const count = zone.filter((r) => r.kind === kind).length; return ( ); })}
))}
setOrigin(e.target.value)} className="max-w-sm" />

{t( 'dnsWizard.originHint', 'Only if {{domain}} lives inside a bigger zone at your host, for example mail.example.com kept in example.com.', { domain: domain.name }, )}

); } // ── Step 4: review ── if (step === 3) { return ( setStep(2)} onNext={finish} busy={busy} nextLabel={t('dnsWizard.publish', 'Connect and publish')} aside={ <>

{choice?.mode === 'new' ? t('dnsWizard.happensNew', 'The {{provider}} connection is saved,', { provider: labelOf(choice.variant), }) : t('dnsWizard.happensExisting', 'Your existing connection is used,')}{' '} {t( 'dnsWizard.happensRest', '{{domain}} switches to automatic DNS, and the server starts writing records straight away. Most hosts show them within a minute or two.', { domain: domain.name }, )}

{t( 'dnsWizard.undoBody', 'If anything fails here, nothing is kept. Later, switching the domain back to manual stops all updates, and the records already written stay where they are.', )}

} >
{t('dnsWizard.reviewDomain', 'Domain')}
{domain.name}
{t('dnsWizard.reviewHost', 'DNS host')}
{choice?.mode === 'existing' ? (servers.find((s) => s.id === choice.id)?.description ?? labelOf(servers.find((s) => s.id === choice.id)?.['@type'] ?? '')) : choice ? labelOf(choice.variant) : ''}
{t('dnsWizard.reviewRecords', 'Records')}
{selectedRecords.length}{' '} {t('dnsWizard.reviewGroups', 'across {{groups}}', { groups: RECORD_GROUPS.filter((g) => g.kinds.some((k) => kinds.has(k.kind))) .map((g) => g.title.toLowerCase()) .join(', '), })}
{origin.trim() && ( <>
{t('dnsWizard.reviewZone', 'Zone')}
{origin.trim()}
)}
{already && (

{t('dnsWizard.alreadyAuto', 'This domain already publishes automatically; this updates its settings.')}

)}
{showZone && }
{error && (

{error}

)}
); } // ── Step 5: live ── return ( navigate(domainView)} /> ); } function CredentialField({ named, schema, name, field, kind, fallback, value, onChange, }: { named?: { label: string; hint?: string }; schema: Schema; name: string; field: Field; kind: Simple; fallback: unknown; value: string | boolean | undefined; onChange: (v: string | boolean) => void; }) { const { t } = useTranslation(); const id = `cred-${name}`; const label = named?.label ?? humanize(name); const optional = isOptional(field, kind) || fallback !== undefined; const heading = ( ); const hint =

{named?.hint ?? field.description}

; if (kind === 'boolean') { return (
{heading} {hint}
); } if (kind === 'enum') { const enumName = (field.type as { enumName: string }).enumName; const options = schema.enums[enumName] ?? []; return (
{heading} {hint}
); } if (kind === 'secretText') { return (
{heading}