/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react'; import { useSchemaStore } from '@/stores/schemaStore'; import { FieldWidget } from '@/components/forms/FieldWidget'; import { FormEditionContext } from '@/components/forms/FormEditionContext'; import { DefaultLogo } from '@/components/common/Logo'; import { toast } from '@/hooks/use-toast'; import { resolveObject, resolveSchema, resolveForm, buildCreateDefaults, deepMerge } from '@/lib/schemaResolver'; import { calculateJmapPatch } from '@/lib/jmapPatch'; import { jmapGet, jmapSet, getAccountId } from '@/services/jmap/client'; import { friendlySetError, validationErrorMessage } from '@/lib/jmapErrors'; import type { Field, Fields, Form, FormField } from '@/types/schema'; import type { JmapSetError, JmapSetResponse } from '@/types/jmap'; const BOOTSTRAP_VIEW = 'x:Bootstrap'; interface RenderableField { formField: FormField; field: Field; } export function BootstrapWizard() { const { t } = useTranslation(); const schema = useSchemaStore((s) => s.schema); const resolved = useMemo(() => { if (!schema) return null; const obj = resolveObject(schema, BOOTSTRAP_VIEW); if (!obj) return null; const sch = resolveSchema(schema, obj.objectName); if (!sch || sch.type !== 'single') return null; const form = resolveForm(schema, BOOTSTRAP_VIEW, obj.objectName, sch.schemaName); return { obj, sch, fields: sch.fields, form }; }, [schema]); const [formData, setFormData] = useState>({}); const [originalData, setOriginalData] = useState>({}); const [currentPage, setCurrentPage] = useState(0); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [fieldErrors, setFieldErrors] = useState>({}); const [generalError, setGeneralError] = useState(null); const [successExtras, setSuccessExtras] = useState | null>(null); const [copied, setCopied] = useState(null); const sections = useMemo((): { title?: string; subtitle?: string; fields: RenderableField[] }[] => { if (!resolved) return []; const { fields, form } = resolved; if (!form) return []; return form.sections .map((section) => { const renderableFields: RenderableField[] = []; for (const ff of section.fields) { const field = fields.properties[ff.name]; if (!field) continue; if (field.update === 'serverSet') continue; if (field.enterprise) continue; renderableFields.push({ formField: ff, field }); } return { title: section.title, fields: renderableFields }; }) .filter((s) => s.fields.length > 0); }, [resolved]); const fetchProperties = useMemo((): string[] => { if (!resolved) return ['id']; const set = new Set(['id']); for (const section of sections) { for (const rf of section.fields) set.add(rf.formField.name); } return Array.from(set); }, [resolved, sections]); useEffect(() => { if (!schema || !resolved) return; const { obj, sch } = resolved; const ctrl = new AbortController(); (async () => { setLoading(true); try { const accountId = getAccountId(obj.objectName); const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal); if (ctrl.signal.aborted) return; const server = (responses[0]?.[1]?.list as Array> | undefined)?.[0] ?? {}; const defaults = buildCreateDefaults(schema, obj, sch); const seeded = deepMerge(defaults, server); setFormData(seeded); setOriginalData({}); } catch (err) { if (ctrl.signal.aborted) return; if (err instanceof DOMException && err.name === 'AbortError') return; setGeneralError( err instanceof Error ? err.message : t('bootstrap.failedToLoad', 'Failed to load bootstrap state.'), ); } finally { if (!ctrl.signal.aborted) setLoading(false); } })(); return () => { ctrl.abort(); }; }, [schema, resolved, fetchProperties, t]); const handleFieldChange = useCallback((fieldName: string, value: unknown) => { setFormData((prev) => ({ ...prev, [fieldName]: value })); setFieldErrors((prev) => { if (!prev[fieldName]) return prev; const copy = { ...prev }; delete copy[fieldName]; return copy; }); }, []); const validateSectionRequired = useCallback( (sectionFields: RenderableField[], data: Record): Record => { const errors: Record = {}; for (const { formField, field } of sectionFields) { if (field.update === 'serverSet' || field.update === 'immutable') continue; const fieldType = field.type; const eligible = fieldType.type === 'string' || fieldType.type === 'number' || fieldType.type === 'utcDateTime' || fieldType.type === 'enum' || fieldType.type === 'blobId' || fieldType.type === 'objectId'; if (!eligible) continue; if ('nullable' in fieldType && fieldType.nullable) continue; const value = data[formField.name]; const isEmpty = value === undefined || value === null || (typeof value === 'string' && value === ''); if (isEmpty) errors[formField.name] = t('form.required', 'This field is required.'); } return errors; }, [t], ); const applySetError = useCallback( (error: JmapSetError): number | null => { setGeneralError(friendlySetError(error)); const fieldToPage = new Map(); sections.forEach((section, idx) => { for (const rf of section.fields) { if (!fieldToPage.has(rf.formField.name)) fieldToPage.set(rf.formField.name, idx); } }); const newErrors: Record = {}; let earliest: number | null = null; const record = (topLevel: string, msg: string) => { if (!fieldToPage.has(topLevel)) return; newErrors[topLevel] = msg; const idx = fieldToPage.get(topLevel)!; if (earliest === null || idx < earliest) earliest = idx; }; if (error.properties) { for (const prop of error.properties) { const top = prop.split('/')[0]; record(top, error.description ?? t('form.invalidValue', 'Invalid value.')); } } if (error.validationErrors) { for (const ve of error.validationErrors) { const top = ve.property?.split('/')[0] ?? ''; if (!top) continue; record(top, validationErrorMessage(ve)); } } if (Object.keys(newErrors).length > 0) setFieldErrors(newErrors); return earliest; }, [sections, t], ); const canGoBack = currentPage > 0; const isLastPage = currentPage === sections.length - 1; const handleNext = useCallback(() => { const current = sections[currentPage]; if (!current) return; const errs = validateSectionRequired(current.fields, formData); if (Object.keys(errs).length > 0) { setFieldErrors(errs); setGeneralError(t('form.correctErrorsBelow', 'Please correct the errors below.')); return; } setGeneralError(null); setFieldErrors({}); setCurrentPage((p) => Math.min(p + 1, sections.length - 1)); }, [currentPage, sections, formData, validateSectionRequired, t]); const handleBack = useCallback(() => { setGeneralError(null); setFieldErrors({}); setCurrentPage((p) => Math.max(p - 1, 0)); }, []); const handleSubmit = useCallback(async () => { if (!resolved) return; const { obj } = resolved; let firstInvalid: number | null = null; const allErrors: Record = {}; sections.forEach((section, idx) => { const errs = validateSectionRequired(section.fields, formData); for (const [k, v] of Object.entries(errs)) { allErrors[k] = v; if (firstInvalid === null) firstInvalid = idx; } }); if (firstInvalid !== null) { setFieldErrors(allErrors); setGeneralError(t('form.correctErrorsBelow', 'Please correct the errors below.')); setCurrentPage(firstInvalid); return; } setSaving(true); setGeneralError(null); setFieldErrors({}); try { const patch = calculateJmapPatch(originalData, formData); const accountId = getAccountId(obj.objectName); const responses = await jmapSet(obj.objectName, accountId, { update: { singleton: patch }, }); const setResult = responses[responses.length - 1]?.[1] as unknown as JmapSetResponse; if (setResult.updated && 'singleton' in setResult.updated) { setSuccessExtras(setResult.updated.singleton ?? {}); } else if (setResult.notUpdated && setResult.notUpdated.singleton) { const earliest = applySetError(setResult.notUpdated.singleton); if (earliest !== null) setCurrentPage(earliest); } else { setGeneralError(t('bootstrap.noConfirm', 'The server did not confirm the update.')); } } catch (err) { setGeneralError( err instanceof Error ? err.message : t('bootstrap.failedToComplete', 'Failed to complete setup.'), ); } finally { setSaving(false); } }, [resolved, sections, formData, originalData, validateSectionRequired, applySetError, t]); const handleCopy = useCallback( async (key: string, value: string) => { try { await navigator.clipboard.writeText(value); setCopied(key); setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500); } catch { toast({ title: t('bootstrap.copyFailed', 'Copy failed'), description: t('bootstrap.clipboardBlocked', 'Your browser blocked clipboard access.'), variant: 'destructive', }); } }, [t], ); if (!schema || !resolved) { return (
{t('form.loadingSchema', 'Loading schema...')}
); } if (loading) { return (
{t('bootstrap.loadingSetup', 'Loading setup...')}
); } if (successExtras !== null) { return ( ); } if (sections.length === 0) { return (
{t('bootstrap.emptyForm', 'Setup form is empty. The server did not return any bootstrap fields.')}
); } const current = sections[currentPage]; return (

{t('bootstrap.welcome', 'Welcome to Stalwart')}

{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}

{generalError && (

{generalError}

)} {current.title && ( {current.title} )}
{current.fields.map(({ formField, field }) => ( handleFieldChange(formField.name, v)} readOnly={saving} error={fieldErrors[formField.name]} schema={schema} /> ))}
{t('bootstrap.stepOf', 'Step {{current}} of {{total}}', { current: currentPage + 1, total: sections.length, })}
{isLastPage ? ( ) : ( )}
); } function WizardShell({ children }: { children: React.ReactNode }) { return (
{children}
); } function ProgressIndicator({ total, current }: { total: number; current: number }) { const { t } = useTranslation(); return (
{Array.from({ length: total }, (_, i) => (
))}
); } function SuccessScreen({ extras, fields, labelsByName, onCopy, copied, }: { extras: Record; fields: Fields; labelsByName: Map; onCopy: (key: string, value: string) => void; copied: string | null; }) { const { t } = useTranslation(); const entries = Object.entries(extras).filter(([k, v]) => { if (k === 'id' || k === 'blobId' || k === '@type') return false; return typeof v === 'string' && v.length > 0; }) as [string, string][]; const hasCredentials = entries.length > 0; return (

{t('bootstrap.complete', 'Setup complete')}

{hasCredentials ? t( 'bootstrap.credentialsCreated', 'Your administrator account has been created. Write these down now: the password will not be shown again.', ) : t('bootstrap.configuredSuccessfully', 'Stalwart has been configured successfully.')}

{hasCredentials && ( {entries.map(([key, value]) => { const field = fields.properties[key]; const label = labelsByName.get(key) ?? field?.description?.split('\n')[0] ?? key; return (
{label}
{value}
); })}
)}

{t('bootstrap.nextStepLabel', 'Next step:')}{' '} {t( 'bootstrap.nextStepBody', 'restart Stalwart for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.', )}

); } function labelsByFieldName(form: Form | null): Map { const map = new Map(); if (!form) return map; for (const section of form.sections) { for (const ff of section.fields) map.set(ff.name, ff.label); } return map; } export default BootstrapWizard;