Files
inbuxa-admin/src/components/bootstrap/BootstrapWizard.tsx
T
jcoffey-dev 7e3418079c
ci / build (pull_request) Successful in 1m1s
ci / publish (pull_request) Skipped
Write the name in lowercase where people see it
The brand is lowercase inbuxa. This changes the setup wizard's welcome and
completion text, the logo's accessible name, the version line in the top bar,
the document title, the legacy-protocols screens and banner, the protocol
table's JMAP row, one help text, and the issuer new authenticator enrollments
are labeled with.

Keys are ids here, not English text, so nothing moved: each catalog value and
its inline fallback changed together. This console ships English only, so
there is no other catalog to follow. Comments, identifiers and env var names
are untouched.
2026-09-22 15:19:25 -07:00

524 lines
18 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
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<Record<string, unknown>>({});
const [originalData, setOriginalData] = useState<Record<string, unknown>>({});
const [currentPage, setCurrentPage] = useState(0);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [generalError, setGeneralError] = useState<string | null>(null);
const [successExtras, setSuccessExtras] = useState<Record<string, unknown> | null>(null);
const [copied, setCopied] = useState<string | null>(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<string>(['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<Record<string, unknown>> | 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<string, unknown>): Record<string, string> => {
const errors: Record<string, string> = {};
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<string, number>();
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<string, string> = {};
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<string, string> = {};
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 (
<WizardShell>
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
{t('form.loadingSchema', 'Loading schema...')}
</div>
</WizardShell>
);
}
if (loading) {
return (
<WizardShell>
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
{t('bootstrap.loadingSetup', 'Loading setup...')}
</div>
</WizardShell>
);
}
if (successExtras !== null) {
return (
<WizardShell>
<SuccessScreen
extras={successExtras}
fields={resolved.fields}
labelsByName={labelsByFieldName(resolved.form)}
onCopy={handleCopy}
copied={copied}
/>
</WizardShell>
);
}
if (sections.length === 0) {
return (
<WizardShell>
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
{t('bootstrap.emptyForm', 'Setup form is empty. The server did not return any bootstrap fields.')}
</div>
</WizardShell>
);
}
const current = sections[currentPage];
return (
<WizardShell>
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to inbuxa')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}
</p>
</div>
<ProgressIndicator total={sections.length} current={currentPage} />
{generalError && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
<p className="text-sm text-destructive whitespace-pre-line">{generalError}</p>
</div>
)}
<Card>
{current.title && (
<CardHeader>
<CardTitle className="text-base">{current.title}</CardTitle>
</CardHeader>
)}
<CardContent className={current.title ? '' : 'pt-6'}>
<div className="space-y-6">
<FormEditionContext.Provider value="oss">
{current.fields.map(({ formField, field }) => (
<FieldWidget
key={formField.name}
field={field}
formField={formField}
value={formData[formField.name]}
onChange={(v) => handleFieldChange(formField.name, v)}
readOnly={saving}
error={fieldErrors[formField.name]}
schema={schema}
/>
))}
</FormEditionContext.Provider>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={handleBack} disabled={!canGoBack || saving}>
<ArrowLeft className="h-4 w-4 mr-2" />
{t('common.back', 'Back')}
</Button>
<div className="text-xs text-muted-foreground">
{t('bootstrap.stepOf', 'Step {{current}} of {{total}}', {
current: currentPage + 1,
total: sections.length,
})}
</div>
{isLastPage ? (
<Button type="button" onClick={handleSubmit} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
{t('bootstrap.finishSetup', 'Finish setup')}
</Button>
) : (
<Button type="button" onClick={handleNext} disabled={saving}>
{t('common.next', 'Next')}
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
)}
</div>
</div>
</WizardShell>
);
}
function WizardShell({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col bg-content-background">
<header className="flex items-center px-6 py-4 border-b bg-background">
<DefaultLogo />
</header>
<main className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-3xl">{children}</div>
</main>
</div>
);
}
function ProgressIndicator({ total, current }: { total: number; current: number }) {
const { t } = useTranslation();
return (
<div
className="flex items-center gap-2"
aria-label={t('bootstrap.stepOf', 'Step {{current}} of {{total}}', { current: current + 1, total })}
>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
className={`h-2 w-2 rounded-full transition-colors ${
i < current ? 'bg-primary' : i === current ? 'bg-primary' : 'bg-muted-foreground/20'
}`}
/>
))}
</div>
);
}
function SuccessScreen({
extras,
fields,
labelsByName,
onCopy,
copied,
}: {
extras: Record<string, unknown>;
fields: Fields;
labelsByName: Map<string, string>;
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 (
<div className="space-y-6">
<div className="flex flex-col items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<Check className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.complete', 'Setup complete')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{hasCredentials
? t(
'bootstrap.credentialsCreated',
'Your administrator account has been created. Write these down now: the password will not be shown again.',
)
: t('bootstrap.configuredSuccessfully', 'inbuxa has been configured successfully.')}
</p>
</div>
</div>
{hasCredentials && (
<Card>
<CardContent className="pt-6 space-y-4">
{entries.map(([key, value]) => {
const field = fields.properties[key];
const label = labelsByName.get(key) ?? field?.description?.split('\n')[0] ?? key;
return (
<div key={key} className="space-y-1.5">
<div className="text-sm font-medium">{label}</div>
<div className="flex gap-2">
<code className="flex-1 rounded bg-muted p-2 text-sm break-all select-all">{value}</code>
<Button type="button" variant="outline" size="sm" onClick={() => onCopy(key, value)}>
{copied === key ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
)}
<div className="rounded-md border border-primary/20 bg-primary/5 p-4">
<p className="text-sm">
<span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '}
{t(
'bootstrap.nextStepBody',
'restart inbuxa for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.',
)}
</p>
</div>
</div>
);
}
function labelsByFieldName(form: Form | null): Map<string, string> {
const map = new Map<string, string>();
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;