From 5641560a918ec077c092c50590b45c1040f5257f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 01:41:00 -0700 Subject: [PATCH] Guided wizards, opt-in every time, and automatic DNS as the first A job that has a wizard now asks "Guide me / I'll do it myself" each time it starts; nothing is remembered. The shared wizard shell gives every guide a stepper, a side panel on what each step does and how to undo it, and the way forward or back. Automatic DNS, from a domain's DNS section: - finds where the domain's DNS is hosted from its SOA and NS records, and offers that host when the server can drive it; - for the major hosts, steps to create the narrowest credential, and the field named as the steps name it; - records grouped by what they do, TLSA off unless the zone is signed; - saves the provider and switches the domain over, removing the provider again if the switch fails; - watches the publishing task and public DNS, ticking each record green, and boils a host's refusal down to its distinct messages; - for hosts it can't drive, or domains not in DNS yet, every record laid out for copying, with the same live checks. --- src/components/forms/DynamicForm.tsx | 14 +- src/components/layout/MainContent.tsx | 18 + src/components/wizard/LaunchChoice.tsx | 92 +++ src/components/wizard/WizardShell.tsx | 148 ++++ src/features/dns/ConnectDnsPage.tsx | 1040 ++++++++++++++++++++++++ src/features/dns/CopyStep.tsx | 204 +++++ src/features/dns/DnsConnectCard.tsx | 63 ++ src/features/dns/detect.test.ts | 28 + src/features/dns/detect.ts | 93 +++ src/features/dns/liveCheck.ts | 90 ++ src/features/dns/parts.tsx | 57 ++ src/features/dns/providers.ts | 155 ++++ src/features/dns/records.ts | 73 ++ src/features/dns/useRecordChecks.ts | 79 ++ src/features/dns/zone.test.ts | 122 +++ src/features/dns/zone.ts | 153 ++++ src/lib/layout.ts | 6 + src/pages/AdminPanel.tsx | 1 + 18 files changed, 2435 insertions(+), 1 deletion(-) create mode 100644 src/components/wizard/LaunchChoice.tsx create mode 100644 src/components/wizard/WizardShell.tsx create mode 100644 src/features/dns/ConnectDnsPage.tsx create mode 100644 src/features/dns/CopyStep.tsx create mode 100644 src/features/dns/DnsConnectCard.tsx create mode 100644 src/features/dns/detect.test.ts create mode 100644 src/features/dns/detect.ts create mode 100644 src/features/dns/liveCheck.ts create mode 100644 src/features/dns/parts.tsx create mode 100644 src/features/dns/providers.ts create mode 100644 src/features/dns/records.ts create mode 100644 src/features/dns/useRecordChecks.ts create mode 100644 src/features/dns/zone.test.ts create mode 100644 src/features/dns/zone.ts diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 9bc433a..b8c8908 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -57,6 +57,7 @@ import { SECRET_MASK } from '@/lib/jmapUtils'; import { toast } from '@/hooks/use-toast'; import { logFormChange } from '@/lib/debug'; import { FieldWidget } from '@/components/forms/FieldWidget'; +import { DnsConnectCard } from '@/features/dns/DnsConnectCard'; import { isSieveScriptField } from '@/lib/sievepad'; import type { Field, Fields, Form, FormField, Schema } from '@/types/schema'; @@ -777,6 +778,17 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { )}
+ {resolved.obj.objectName === 'x:Domain' && + objectId && + !readOnly && + section.fields.some((sf) => sf.formField.name === 'dnsManagement') && ( + + )} {section.fields.map((sf) => { const { formField, field, visible, enterpriseDisabled } = sf; if (!visible) return null; @@ -833,7 +845,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
{widget}
-

{t('enterprise.featureDisabled', 'This feature isn\'t available on this server.')}

+

{t('enterprise.featureDisabled', "This feature isn't available on this server.")}

diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 62892a3..2f822af 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ @@ -35,6 +36,10 @@ const TraceDetailView = lazyFeature( () => import('@/features/tracing/components/TraceDetailView'), (m) => m.TraceDetailView, ); +const ConnectDnsPage = lazyFeature( + () => import('@/features/dns/ConnectDnsPage'), + (m) => m.ConnectDnsPage, +); const ActionPage = lazyFeature( () => import('@/features/actions/ActionPage'), (m) => m.ActionPage, @@ -67,6 +72,19 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti return ; } + // INBUXA: guided jobs. Always reached by choosing "Guide me", never by default. + if (viewName.startsWith('Wizard/')) { + const [, wizard, param] = viewName.split('/'); + if (wizard === 'dns' && param) { + return ; + } + return ( +
+ Unknown guide: {wizard} +
+ ); + } + if (viewName.startsWith('CustomComponent/')) { const componentName = viewName.slice('CustomComponent/'.length); if (componentName === 'Dashboard') { diff --git a/src/components/wizard/LaunchChoice.tsx b/src/components/wizard/LaunchChoice.tsx new file mode 100644 index 0000000..4e4933f --- /dev/null +++ b/src/components/wizard/LaunchChoice.tsx @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useTranslation } from 'react-i18next'; +import { ListChecks, SlidersHorizontal } from 'lucide-react'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; + +/** + * "Guided or manual?" Every job that has a wizard asks this each time it + * starts. Nothing is remembered: the wizard is always opt-in. + */ +export function LaunchChoice({ + open, + onOpenChange, + title, + guidedHint, + manualHint, + onGuided, + onManual, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + guidedHint: string; + manualHint?: string; + onGuided: () => void; + onManual: () => void; +}) { + const { t } = useTranslation(); + const option = ( + icon: typeof ListChecks, + heading: string, + hint: string, + onClick: () => void, + accent: boolean, + autoFocus: boolean, + ) => { + const Icon = icon; + return ( + + ); + }; + + return ( + + + + {title} + {t('wizard.chooseHow', 'How would you like to do this?')} + +
+ {option(ListChecks, t('wizard.guided', 'Guide me'), guidedHint, onGuided, true, true)} + {option( + SlidersHorizontal, + t('wizard.manual', "I'll do it myself"), + manualHint ?? t('wizard.manualHint', 'The full form, with every option at once.'), + onManual, + false, + false, + )} +
+
+
+ ); +} diff --git a/src/components/wizard/WizardShell.tsx b/src/components/wizard/WizardShell.tsx new file mode 100644 index 0000000..7e0f5af --- /dev/null +++ b/src/components/wizard/WizardShell.tsx @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ArrowLeft, ArrowRight, Check, Loader2, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { PageHeader } from '@/components/common/PageHeader'; +import { cn } from '@/lib/utils'; + +export interface WizardStep { + id: string; + title: string; +} + +/** + * The frame every guided job shares: where you are in it, the step itself, + * a side panel saying what this step does (and how to undo it, for the big + * jobs), and the way forward or back. The steps own their content and decide + * when "Next" is allowed; the shell never does anything on its own. + */ +export function WizardShell({ + icon, + title, + subtitle, + steps, + current, + children, + aside, + canNext = true, + busy = false, + nextLabel, + onBack, + onNext, + onCancel, + hideFooter = false, +}: { + icon: string; + title: ReactNode; + subtitle?: ReactNode; + steps: WizardStep[]; + current: number; + children: ReactNode; + aside?: ReactNode; + canNext?: boolean; + busy?: boolean; + nextLabel?: string; + onBack?: () => void; + onNext?: () => void; + onCancel: () => void; + hideFooter?: boolean; +}) { + const { t } = useTranslation(); + return ( +
+ + + {t('wizard.close', 'Close')} + + } + /> + +
    + {steps.map((s, i) => { + const done = i < current; + const here = i === current; + return ( +
  1. + + {done ? : i + 1} + + + {s.title} + + {i < steps.length - 1 && } +
  2. + ); + })} +
+ +
+ + {children} + + {aside && } +
+ + {!hideFooter && ( +
+ {onBack ? ( + + ) : ( + + )} + {onNext && ( + + )} +
+ )} +
+ ); +} + +/** A side-panel note: what this step does, or how to undo it. */ +export function WizardNote({ + title, + children, + tone = 'plain', +}: { + title: string; + children: ReactNode; + tone?: 'plain' | 'undo'; +}) { + return ( +
+

{title}

+
{children}
+
+ ); +} diff --git a/src/features/dns/ConnectDnsPage.tsx b/src/features/dns/ConnectDnsPage.tsx new file mode 100644 index 0000000..3de6309 --- /dev/null +++ b/src/features/dns/ConnectDnsPage.tsx @@ -0,0 +1,1040 @@ +/* + * 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} +