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:
@@ -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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user