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.
This commit is contained in:
2026-09-19 01:41:00 -07:00
parent 2e02532279
commit 5641560a91
18 changed files with 2435 additions and 1 deletions
+13 -1
View File
@@ -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) {
)}
<CardContent className={section.title ? '' : 'pt-6'}>
<div className="space-y-6">
{resolved.obj.objectName === 'x:Domain' &&
objectId &&
!readOnly &&
section.fields.some((sf) => sf.formField.name === 'dnsManagement') && (
<DnsConnectCard
domainId={objectId}
automatic={
(originalData.dnsManagement as { '@type'?: string } | undefined)?.['@type'] === 'Automatic'
}
/>
)}
{section.fields.map((sf) => {
const { formField, field, visible, enterpriseDisabled } = sf;
if (!visible) return null;
@@ -833,7 +845,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
<div className="opacity-60">{widget}</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('enterprise.featureDisabled', 'This feature isn\'t available on this server.')}</p>
<p>{t('enterprise.featureDisabled', "This feature isn't available on this server.")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
+18
View File
@@ -1,5 +1,6 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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 <DashboardView dashboardId={dashboardId} section={section ?? ''} />;
}
// 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 <ConnectDnsPage domainId={param} />;
}
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown guide: {wizard}
</div>
);
}
if (viewName.startsWith('CustomComponent/')) {
const componentName = viewName.slice('CustomComponent/'.length);
if (componentName === 'Dashboard') {
+92
View File
@@ -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 (
<button
type="button"
autoFocus={autoFocus}
onClick={() => {
onOpenChange(false);
onClick();
}}
className={cn(
'group flex flex-col items-start gap-3 rounded-xl border p-5 text-left transition-all',
'hover:-translate-y-0.5 hover:border-primary/60 hover:shadow-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
accent ? 'border-primary/40 bg-primary/5' : 'border-border bg-card',
)}
>
<span
className={cn(
'flex h-10 w-10 items-center justify-center rounded-xl',
accent ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground',
)}
>
<Icon className="h-5 w-5" />
</span>
<span className="font-medium">{heading}</span>
<span className="text-sm text-muted-foreground">{hint}</span>
</button>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{t('wizard.chooseHow', 'How would you like to do this?')}</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
{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,
)}
</div>
</DialogContent>
</Dialog>
);
}
+148
View File
@@ -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 (
<div className="mx-auto max-w-5xl space-y-6">
<PageHeader
icon={icon}
title={title}
subtitle={subtitle}
actions={
<Button variant="ghost" onClick={onCancel}>
<X className="h-4 w-4" />
{t('wizard.close', 'Close')}
</Button>
}
/>
<ol className="flex flex-wrap items-center gap-x-2 gap-y-3" aria-label={t('wizard.progress', 'Progress')}>
{steps.map((s, i) => {
const done = i < current;
const here = i === current;
return (
<li key={s.id} className="flex items-center gap-2" aria-current={here ? 'step' : undefined}>
<span
className={cn(
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors',
done && 'bg-primary text-primary-foreground',
here && 'bg-primary/15 text-primary ring-2 ring-primary',
!done && !here && 'bg-muted text-muted-foreground',
)}
>
{done ? <Check className="h-3.5 w-3.5" /> : i + 1}
</span>
<span className={cn('text-sm', here ? 'font-medium text-foreground' : 'text-muted-foreground')}>
{s.title}
</span>
{i < steps.length - 1 && <span className="mx-1 hidden h-px w-8 bg-border sm:block" aria-hidden />}
</li>
);
})}
</ol>
<div className={cn('grid items-start gap-6', aside && 'lg:grid-cols-[minmax(0,1fr)_18rem]')}>
<Card>
<CardContent className="space-y-6 pt-6">{children}</CardContent>
</Card>
{aside && <aside className="space-y-4 text-sm">{aside}</aside>}
</div>
{!hideFooter && (
<div className="flex items-center justify-between">
{onBack ? (
<Button variant="ghost" onClick={onBack} disabled={busy}>
<ArrowLeft className="h-4 w-4" />
{t('wizard.back', 'Back')}
</Button>
) : (
<span />
)}
{onNext && (
<Button onClick={onNext} disabled={!canNext || busy}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{nextLabel ?? t('wizard.next', 'Next')}
{!busy && <ArrowRight className="h-4 w-4" />}
</Button>
)}
</div>
)}
</div>
);
}
/** 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 (
<div
className={cn(
'rounded-xl border p-4',
tone === 'undo' ? 'border-emerald-500/30 bg-emerald-500/5' : 'border-border bg-card',
)}
>
<p className="mb-1.5 font-medium">{title}</p>
<div className="space-y-2 text-muted-foreground">{children}</div>
</div>
);
}