Rewrite the actions page #128

Merged
jcoffey-dev merged 2 commits from phase4/actions into main 2026-10-19 04:17:38 +00:00
6 changed files with 970 additions and 741 deletions

No files matched your search

+203 -347
View File
@@ -1,274 +1,132 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
/**
* First-run setup: the server's `x:Bootstrap` form, one section per step,
* saved in one update at the end. Shown instead of the console while the
* signed-in account can see nothing but this.
*/
import { useCallback, useEffect, useMemo, useState, type ReactNode } 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 { DefaultLogo } from '@/components/common/Logo';
import { FieldWidget } from '@/components/forms/FieldWidget';
import { FormEditionContext } from '@/components/forms/FormEditionContext';
import { DefaultLogo } from '@/components/common/Logo';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { buildCreateDefaults, deepMerge, resolveForm, resolveObject, resolveSchema } from '@/lib/schemaResolver';
import { getAccountId, jmapGet, jmapSet } from '@/services/jmap/client';
import { useSchemaStore } from '@/stores/schemaStore';
import {
credentials,
fieldLabels,
firstMissing,
missingOnPage,
readSetResult,
refusalByField,
wizardPages,
} from './wizard';
import type { Field, Fields, Form, FormField } from '@/types/schema';
import type { JmapSetError, JmapSetResponse } from '@/types/jmap';
const VIEW = 'x:Bootstrap';
const BOOTSTRAP_VIEW = 'x:Bootstrap';
/** The singleton's id; there is only one bootstrap object. */
const SINGLETON = 'singleton';
interface RenderableField {
formField: FormField;
field: Field;
/** The bootstrap object, its fields and its form, once the schema is in. */
function useBootstrapSchema() {
const schema = useSchemaStore((s) => s.schema);
return useMemo(() => {
const object = schema && resolveObject(schema, VIEW);
const resolved = object && resolveSchema(schema, object.objectName);
if (!schema || !object || resolved?.type !== 'single') return null;
const form = resolveForm(schema, VIEW, object.objectName, resolved.schemaName);
return { schema, object, resolved, form, pages: wizardPages(resolved.fields, form) };
}, [schema]);
}
export function BootstrapWizard() {
const { t } = useTranslation();
const schema = useSchemaStore((s) => s.schema);
const setup = useBootstrapSchema();
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 [values, setValues] = useState<Record<string, unknown>>({});
const [step, setStep] = 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 [error, setError] = useState<string | null>(null);
const [generated, setGenerated] = 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]);
// Start from the schema's defaults, with whatever the server already has on top.
useEffect(() => {
if (!schema || !resolved) return;
const { obj, sch } = resolved;
const ctrl = new AbortController();
(async () => {
if (!setup) return;
const { schema, object, resolved, pages } = setup;
const properties = ['id', ...new Set(pages.flatMap((p) => p.fields.map((f) => f.formField.name)))];
const abort = new AbortController();
void (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.'),
const responses = await jmapGet(
object.objectName,
getAccountId(object.objectName),
[SINGLETON],
properties,
abort.signal,
);
if (abort.signal.aborted) return;
const current = (responses[0]?.[1]?.list as Record<string, unknown>[] | undefined)?.[0] ?? {};
setValues(deepMerge(buildCreateDefaults(schema, object, resolved), current));
} catch (err) {
if (abort.signal.aborted || (err instanceof DOMException && err.name === 'AbortError')) return;
setError(err instanceof Error ? err.message : t('bootstrap.failedToLoad', 'Failed to load bootstrap state.'));
} finally {
if (!ctrl.signal.aborted) setLoading(false);
if (!abort.signal.aborted) setLoading(false);
}
})();
return () => abort.abort();
}, [setup, t]);
return () => {
ctrl.abort();
};
}, [schema, resolved, fetchProperties, t]);
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
const change = useCallback((name: string, value: unknown) => {
setValues((prev) => ({ ...prev, [name]: value }));
setFieldErrors((prev) => {
if (!prev[fieldName]) return prev;
const copy = { ...prev };
delete copy[fieldName];
return copy;
if (!(name in prev)) return prev;
const { [name]: _cleared, ...rest } = prev;
void _cleared;
return rest;
});
}, []);
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 required = t('form.required', 'This field is required.');
const correctBelow = () => t('form.correctErrorsBelow', 'Please correct the errors below.');
const applySetError = useCallback(
(error: JmapSetError): number | null => {
setGeneralError(friendlySetError(error));
/** Shows errors and moves to the page that has the first one. */
const showErrors = (errors: Record<string, string>, page: number | null) => {
setFieldErrors(errors);
if (page !== null) setStep(page);
};
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);
const clearErrors = () => {
setError(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));
}, []);
if (!setup) {
return <Waiting>{t('form.loadingSchema', 'Loading schema...')}</Waiting>;
}
if (loading) {
return <Waiting>{t('bootstrap.loadingSetup', 'Loading setup...')}</Waiting>;
}
const handleSubmit = useCallback(async () => {
if (!resolved) return;
const { obj } = resolved;
const { schema, object, resolved, form, pages } = setup;
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) => {
if (generated !== null) {
const copy = async (key: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(key);
@@ -280,60 +138,86 @@ export function BootstrapWizard() {
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}
<Shell>
<Finished
values={credentials(generated)}
label={fieldLabels(form, resolved.fields)}
copied={copied}
onCopy={copy}
/>
</WizardShell>
</Shell>
);
}
if (sections.length === 0) {
if (pages.length === 0) {
return (
<WizardShell>
<Shell>
<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>
</Shell>
);
}
const current = sections[currentPage];
const page = pages[step];
const last = step === pages.length - 1;
const next = () => {
const missing = missingOnPage(page, values, required);
if (Object.keys(missing).length > 0) {
setFieldErrors(missing);
setError(correctBelow());
return;
}
clearErrors();
setStep(Math.min(step + 1, pages.length - 1));
};
const back = () => {
clearErrors();
setStep(Math.max(step - 1, 0));
};
const finish = async () => {
const missing = firstMissing(pages, values, required);
if (missing.page !== null) {
setError(correctBelow());
showErrors(missing.errors, missing.page);
return;
}
setSaving(true);
clearErrors();
try {
// Everything goes in the patch: setup starts from nothing on the server.
const responses = await jmapSet(object.objectName, getAccountId(object.objectName), {
update: { [SINGLETON]: calculateJmapPatch({}, values) },
});
const outcome = readSetResult(responses.at(-1)?.[1]);
if (outcome.kind === 'updated') {
setGenerated(outcome.extras);
} else if (outcome.kind === 'refused') {
setError(friendlySetError(outcome.error));
const refusal = refusalByField(outcome.error, pages, {
invalid: t('form.invalidValue', 'Invalid value.'),
validation: validationErrorMessage,
});
if (Object.keys(refusal.errors).length > 0) showErrors(refusal.errors, refusal.page);
} else {
setError(t('bootstrap.noConfirm', 'The server did not confirm the update.'));
}
} catch (err) {
setError(err instanceof Error ? err.message : t('bootstrap.failedToComplete', 'Failed to complete setup.'));
} finally {
setSaving(false);
}
};
const stepOf = t('bootstrap.stepOf', 'Step {{current}} of {{total}}', { current: step + 1, total: pages.length });
return (
<WizardShell>
<Shell>
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to inbuxa')}</h2>
@@ -342,30 +226,38 @@ export function BootstrapWizard() {
</p>
</div>
<ProgressIndicator total={sections.length} current={currentPage} />
<div className="flex items-center gap-2" aria-label={stepOf}>
{pages.map((_, i) => (
<div
key={i}
className={`h-2 w-2 rounded-full transition-colors ${i <= step ? 'bg-primary' : 'bg-muted-foreground/20'}`}
/>
))}
</div>
{generalError && (
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
<p className="text-sm text-destructive whitespace-pre-line">{generalError}</p>
<p className="text-sm text-destructive whitespace-pre-line">{error}</p>
</div>
)}
<Card>
{current.title && (
{page.title && (
<CardHeader>
<CardTitle className="text-base">{current.title}</CardTitle>
<CardTitle className="text-base">{page.title}</CardTitle>
</CardHeader>
)}
<CardContent className={current.title ? '' : 'pt-6'}>
<CardContent className={page.title ? '' : 'pt-6'}>
<div className="space-y-6">
{/* Enterprise-only options stay out of setup. */}
<FormEditionContext.Provider value="oss">
{current.fields.map(({ formField, field }) => (
{page.fields.map(({ formField, field }) => (
<FieldWidget
key={formField.name}
field={field}
formField={formField}
value={formData[formField.name]}
onChange={(v) => handleFieldChange(formField.name, v)}
value={values[formField.name]}
onChange={(v) => change(formField.name, v)}
readOnly={saving}
error={fieldErrors[formField.name]}
schema={schema}
@@ -377,34 +269,30 @@ export function BootstrapWizard() {
</Card>
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={handleBack} disabled={!canGoBack || saving}>
<Button type="button" variant="outline" onClick={back} disabled={step === 0 || 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}>
<div className="text-xs text-muted-foreground">{stepOf}</div>
{last ? (
<Button type="button" onClick={finish} 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}>
<Button type="button" onClick={next} disabled={saving}>
{t('common.next', 'Next')}
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
)}
</div>
</div>
</WizardShell>
</Shell>
);
}
function WizardShell({ children }: { children: React.ReactNode }) {
/** The page around every step: the logo bar and a centered column. */
function Shell({ children }: { children: 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">
@@ -417,46 +305,27 @@ function WizardShell({ children }: { children: React.ReactNode }) {
);
}
function ProgressIndicator({ total, current }: { total: number; current: number }) {
const { t } = useTranslation();
function Waiting({ children }: { children: ReactNode }) {
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>
<Shell>
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
{children}
</div>
</Shell>
);
}
function SuccessScreen({
extras,
fields,
labelsByName,
onCopy,
copied,
}: {
extras: Record<string, unknown>;
fields: Fields;
labelsByName: Map<string, string>;
onCopy: (key: string, value: string) => void;
interface FinishedProps {
values: [string, string][];
label: (name: string) => string;
copied: string | null;
}) {
onCopy: (key: string, value: string) => void;
}
/** Setup is done: what the server generated, to write down now, and the restart that comes next. */
function Finished({ values, label, copied, onCopy }: FinishedProps) {
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">
@@ -466,7 +335,7 @@ function SuccessScreen({
<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
{values.length > 0
? t(
'bootstrap.credentialsCreated',
'Your administrator account has been created. Write these down now: the password will not be shown again.',
@@ -476,24 +345,20 @@ function SuccessScreen({
</div>
</div>
{hasCredentials && (
{values.length > 0 && (
<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>
{values.map(([key, value]) => (
<div key={key} className="space-y-1.5">
<div className="text-sm font-medium">{label(key)}</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>
)}
@@ -511,13 +376,4 @@ function SuccessScreen({
);
}
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;
+170
View File
@@ -0,0 +1,170 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import type { Field, Fields, Form } from '@/types/schema';
import {
credentials,
fieldLabels,
firstMissing,
missingOnPage,
readSetResult,
refusalByField,
wizardPages,
} from './wizard';
const field = (type: Record<string, unknown>, more: Partial<Field> = {}) =>
({ description: 'Desc line\nmore', type, update: 'mutable', ...more }) as Field;
const fields: Fields = {
properties: {
host: field({ type: 'string' }),
domain: field({ type: 'string' }),
note: field({ type: 'string', nullable: true }),
tls: field({ type: 'boolean' }),
id: field({ type: 'string' }, { update: 'serverSet' }),
paid: field({ type: 'string' }, { enterprise: true }),
store: field({ type: 'object' }),
kind: field({ type: 'enum' }),
fixed: field({ type: 'string' }, { update: 'immutable' }),
},
};
const form: Form = {
sections: [
{
title: 'Identity',
fields: [
{ name: 'host', label: 'Hostname' },
{ name: 'domain', label: 'Domain' },
{ name: 'note', label: 'Note' },
{ name: 'tls', label: 'TLS' },
{ name: 'ghost', label: 'Not in the schema' },
],
},
{
title: 'Server only',
fields: [
{ name: 'id', label: 'Id' },
{ name: 'paid', label: 'Paid' },
],
},
{
fields: [
{ name: 'store', label: 'Store' },
{ name: 'kind', label: 'Kind' },
{ name: 'fixed', label: 'Fixed' },
],
},
],
};
const pages = wizardPages(fields, form);
describe('wizardPages', () => {
it('keeps the fields an admin fills in, and drops pages left empty', () => {
expect(pages.map((p) => [p.title, p.fields.map((f) => f.formField.name)])).toEqual([
['Identity', ['host', 'domain', 'note', 'tls']],
[undefined, ['store', 'kind', 'fixed']],
]);
expect(pages[0].fields[0].field).toBe(fields.properties.host);
});
it('has no pages without a form', () => {
expect(wizardPages(fields, null)).toEqual([]);
});
});
describe('required fields', () => {
it('asks for plain values that are empty and not optional', () => {
expect(missingOnPage(pages[0], { host: '', note: '', tls: undefined }, 'Required')).toEqual({
host: 'Required',
domain: 'Required',
});
expect(missingOnPage(pages[0], { host: 'mail.a.org', domain: 'a.org' }, 'Required')).toEqual({});
});
it('leaves objects and fields that cannot change to their own checks', () => {
expect(missingOnPage(pages[1], { store: null, kind: null, fixed: null }, 'Required')).toEqual({ kind: 'Required' });
});
it('finds every missing field and the first page with one', () => {
expect(firstMissing(pages, { host: 'h', domain: 'd' }, 'Required')).toEqual({
errors: { kind: 'Required' },
page: 1,
});
expect(firstMissing(pages, { kind: 'x' }, 'Required')).toEqual({
errors: { host: 'Required', domain: 'Required' },
page: 0,
});
expect(firstMissing(pages, { host: 'h', domain: 'd', kind: 'x' }, 'Required')).toEqual({ errors: {}, page: null });
});
});
describe('refusalByField', () => {
const messages = { invalid: 'Invalid value.', validation: (ve: { type: string }) => `bad ${ve.type}` };
it('puts the server’s reasons on the fields they name, by their top-level name', () => {
expect(
refusalByField(
{
type: 'invalidProperties',
description: 'Nope',
properties: ['kind/value', 'unknown'],
validationErrors: [{ type: 'MaxLength', property: 'domain', required: 3 }],
},
pages,
messages,
),
).toEqual({ errors: { kind: 'Nope', domain: 'bad MaxLength' }, page: 0 });
});
it('uses a plain message when the server gives none, and names no page when no field is shown', () => {
expect(refusalByField({ type: 'invalidProperties', properties: ['store'] }, pages, messages)).toEqual({
errors: { store: 'Invalid value.' },
page: 1,
});
expect(refusalByField({ type: 'forbidden', properties: ['id'] }, pages, messages)).toEqual({
errors: {},
page: null,
});
});
});
describe('readSetResult', () => {
it('reads an update, a refusal, or neither', () => {
expect(readSetResult({ updated: { singleton: { adminPassword: 'x' } } })).toEqual({
kind: 'updated',
extras: { adminPassword: 'x' },
});
expect(readSetResult({ updated: { singleton: null } })).toEqual({ kind: 'updated', extras: {} });
expect(readSetResult({ notUpdated: { singleton: { type: 'forbidden' } } })).toEqual({
kind: 'refused',
error: { type: 'forbidden' },
});
expect(readSetResult({ updated: null, notUpdated: null })).toEqual({ kind: 'unconfirmed' });
expect(readSetResult(undefined)).toEqual({ kind: 'unconfirmed' });
});
});
describe('the finish screen', () => {
it('shows the values the server generated, not ids or empty ones', () => {
expect(
credentials({ id: 's', blobId: 'b', '@type': 'X', adminUser: 'admin', empty: '', count: 3, pass: 'p' }),
).toEqual([
['adminUser', 'admin'],
['pass', 'p'],
]);
});
it('labels a value by its form label, else the first line of its description, else its name', () => {
const labels = fieldLabels(form, fields);
expect(labels('host')).toBe('Hostname');
expect(labels('id')).toBe('Id');
expect(fieldLabels(null, fields)('host')).toBe('Desc line');
expect(labels('adminPassword')).toBe('adminPassword');
});
});
+131
View File
@@ -0,0 +1,131 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** The setup wizard's rules, apart from the page: its pages, what each requires, and what the server answers. */
import type { JmapSetError, ValidationError } from '@/types/jmap';
import type { Field, Fields, Form, FormField } from '@/types/schema';
export interface WizardField {
formField: FormField;
field: Field;
}
export interface WizardPage {
title?: string;
fields: WizardField[];
}
/** Field errors by field name, and the first page that has one (null when none does). */
export interface PageErrors {
errors: Record<string, string>;
page: number | null;
}
/**
* One page per form section, with the fields an admin fills in: not ones
* the server sets, nor Enterprise ones. A section left with none is skipped.
*/
export function wizardPages(fields: Fields, form: Form | null): WizardPage[] {
if (!form) return [];
return form.sections
.map((section) => ({
title: section.title,
fields: section.fields.flatMap((formField) => {
const field = fields.properties[formField.name];
return field && field.update !== 'serverSet' && !field.enterprise ? [{ formField, field }] : [];
}),
}))
.filter((page) => page.fields.length > 0);
}
/** Plain values can be checked for presence here; objects, lists and maps check themselves. */
const PLAIN = new Set(['string', 'number', 'utcDateTime', 'enum', 'blobId', 'objectId']);
/** The page's required fields that are still empty. */
export function missingOnPage(
page: WizardPage,
data: Record<string, unknown>,
required: string,
): Record<string, string> {
const errors: Record<string, string> = {};
for (const { formField, field } of page.fields) {
if (field.update !== 'mutable' || !PLAIN.has(field.type.type)) continue;
if ('nullable' in field.type && field.type.nullable) continue;
const value = data[formField.name];
if (value === undefined || value === null || value === '') errors[formField.name] = required;
}
return errors;
}
/** Every required field still empty, on any page. */
export function firstMissing(pages: WizardPage[], data: Record<string, unknown>, required: string): PageErrors {
const result: PageErrors = { errors: {}, page: null };
pages.forEach((page, index) => {
const missing = missingOnPage(page, data, required);
if (Object.keys(missing).length === 0) return;
Object.assign(result.errors, missing);
result.page ??= index;
});
return result;
}
/**
* The server's refusal, on the fields it names. A property path like
* `dataStore/path` belongs to the top-level field `dataStore`; fields
* the wizard doesn't show are left to the general message.
*/
export function refusalByField(
error: JmapSetError,
pages: WizardPage[],
messages: { invalid: string; validation: (ve: ValidationError) => string },
): PageErrors {
const pageOf = new Map<string, number>();
pages.forEach((page, index) => {
for (const { formField } of page.fields) if (!pageOf.has(formField.name)) pageOf.set(formField.name, index);
});
const result: PageErrors = { errors: {}, page: null };
const mark = (path: string | undefined, message: string) => {
const name = path?.split('/')[0];
const page = name ? pageOf.get(name) : undefined;
if (!name || page === undefined) return;
result.errors[name] = message;
if (result.page === null || page < result.page) result.page = page;
};
for (const path of error.properties ?? []) mark(path, error.description ?? messages.invalid);
for (const ve of error.validationErrors ?? []) mark(ve.property, messages.validation(ve));
return result;
}
export type SetOutcome =
| { kind: 'updated'; extras: Record<string, unknown> }
| { kind: 'refused'; error: JmapSetError }
| { kind: 'unconfirmed' };
/** What `x:Bootstrap/set` said about the singleton: done (with anything it generated), refused, or nothing. */
export function readSetResult(result: Record<string, unknown> | undefined): SetOutcome {
const updated = result?.updated as Record<string, Record<string, unknown> | null> | null | undefined;
const notUpdated = result?.notUpdated as Record<string, JmapSetError> | null | undefined;
if (updated && 'singleton' in updated) return { kind: 'updated', extras: updated.singleton ?? {} };
if (notUpdated?.singleton) return { kind: 'refused', error: notUpdated.singleton };
return { kind: 'unconfirmed' };
}
const NOT_SHOWN = new Set(['id', 'blobId', '@type']);
/** What setup generated and the admin must write down, such as the first account's password. */
export function credentials(extras: Record<string, unknown>): [string, string][] {
return Object.entries(extras).filter(
(entry): entry is [string, string] => !NOT_SHOWN.has(entry[0]) && typeof entry[1] === 'string' && entry[1] !== '',
);
}
/** A field's name for people: its form label, else its description's first line, else the name itself. */
export function fieldLabels(form: Form | null, fields: Fields): (name: string) => string {
const labels = new Map<string, string>();
for (const section of form?.sections ?? []) for (const ff of section.fields) labels.set(ff.name, ff.label);
return (name) => labels.get(name) ?? fields.properties[name]?.description?.split('\n')[0] ?? name;
}
+219 -394
View File
@@ -1,180 +1,105 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Zap, Loader2, ChevronRight, CircleCheck, CircleAlert } from 'lucide-react';
import i18n from '@/i18n';
import { useSearchParams } from 'react-router-dom';
import { ChevronRight, CircleAlert, CircleCheck, Loader2, Zap } from 'lucide-react';
import { FieldWidget } from '@/components/forms/FieldWidget';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useSchemaStore } from '@/stores/schemaStore';
import { useAccountStore } from '@/stores/accountStore';
import { resolveObject, resolveSchema, resolveVariantForm } from '@/lib/schemaResolver';
import { SECRET_MASK } from '@/lib/jmapUtils';
import { jmapSet, getAccountId } from '@/services/jmap/client';
import { FieldWidget } from '@/components/forms/FieldWidget';
import { DynamicView } from '@/components/views/DynamicView';
import type { Schema, ObjectVariant } from '@/types/schema';
import type { JmapSetResponse } from '@/types/jmap';
import { ExplainButton } from '@/features/ai/explain/ExplainButton';
import { spamVerdictSubject } from '@/features/ai/explain/explain';
import { resolveObject, resolveSchema, resolveVariantForm } from '@/lib/schemaResolver';
import { getAccountId, jmapSet } from '@/services/jmap/client';
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
import type { ObjectVariant, Schema } from '@/types/schema';
import {
actionPayload,
CREATE_ID,
groupActions,
inputSections,
missingInputs,
readActionResult,
splitLabel,
} from './actions';
type ActionState =
type Stage =
| { kind: 'pick' }
// inbuxa: an action without options, chosen from the command palette, waits to be confirmed.
/** An action without options, opened from the command palette, waits for Run. */
| { kind: 'confirm'; variant: ObjectVariant }
| { kind: 'form'; variant: ObjectVariant; formData: Record<string, unknown> }
| { kind: 'submitting'; variant: ObjectVariant }
| {
kind: 'result';
variant: ObjectVariant;
props: Record<string, unknown> | null;
inputData: Record<string, unknown>;
}
| { kind: 'error'; variant: ObjectVariant; error: string };
| { kind: 'form'; variant: ObjectVariant; values: Record<string, unknown> }
| { kind: 'running'; variant: ObjectVariant }
| { kind: 'done'; variant: ObjectVariant; values: Record<string, unknown>; returned: Record<string, unknown> | null }
| { kind: 'failed'; error: string };
interface ActionPageProps {
viewName: string;
}
/** An action's options, starting from the schema's defaults. */
const defaultsFor = (schema: Schema, variant: ObjectVariant) => ({
...(variant.schemaName ? schema.fields[variant.schemaName]?.defaults : undefined),
});
export function ActionPage({ viewName }: ActionPageProps) {
export function ActionPage({ viewName }: { viewName: string }) {
const { t } = useTranslation();
const schema = useSchemaStore((s) => s.schema);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const hasPermission = useAccountStore((s) => s.hasPermission);
const [state, setState] = useState<ActionState>({ kind: 'pick' });
const [stage, setStage] = useState<Stage>({ kind: 'pick' });
const [searchParams, setSearchParams] = useSearchParams();
const resolved = useMemo(() => {
if (!schema) return null;
return resolveObject(schema, viewName);
}, [schema, viewName]);
const object = useMemo(() => (schema ? resolveObject(schema, viewName) : null), [schema, viewName]);
const variants = useMemo(() => {
const resolved = schema && object ? resolveSchema(schema, object.objectName) : null;
return resolved?.type === 'multiple' ? resolved.variants : null;
}, [schema, object]);
const canRun = object ? hasObjectPermission(object.permissionPrefix, 'Create') : false;
const allowed = (name: string) => hasPermission(`action${name}`);
const sch = useMemo(() => {
if (!schema || !resolved) return null;
return resolveSchema(schema, resolved.objectName);
}, [schema, resolved]);
const canCreate = resolved ? hasObjectPermission(resolved.permissionPrefix, 'Create') : false;
const groups = useMemo(() => {
const variants = sch?.type === 'multiple' ? sch.variants : [];
const map = new Map<string, ObjectVariant[]>();
const otherLabel = t('actions.otherGroup', 'Other');
for (const v of variants) {
if (!hasPermission(`action${v.name}`)) continue;
const colonIdx = v.label.indexOf(':');
const group = colonIdx > 0 ? v.label.slice(0, colonIdx).trim() : otherLabel;
let list = map.get(group);
if (!list) {
list = [];
map.set(group, list);
}
list.push(v);
const run = async (variant: ObjectVariant, values: Record<string, unknown>) => {
if (!schema || !object) return;
setStage({ kind: 'running', variant });
const fields = variant.schemaName ? (schema.fields[variant.schemaName] ?? null) : null;
try {
const responses = await jmapSet(object.objectName, getAccountId(object.objectName), {
create: { [CREATE_ID]: actionPayload(variant.name, fields, values) },
});
const result = readActionResult(responses.at(-1)?.[1]);
setStage(
result.kind === 'done'
? { kind: 'done', variant, values, returned: result.returned }
: { kind: 'failed', error: result.reason ?? t('actions.actionFailed', 'Action failed.') },
);
} catch (e) {
setStage({ kind: 'failed', error: e instanceof Error ? e.message : t('actions.actionFailed', 'Action failed.') });
}
return map;
}, [sch, hasPermission, t]);
};
const list = schema?.lists[viewName] ?? schema?.lists[resolved?.objectName ?? ''];
const title = list?.title ?? t('actions.title', 'Actions');
const subtitle = list?.subtitle ?? resolved?.objectType?.description;
/** A click on an action runs it at once when it has no options, else opens them. */
const open = (variant: ObjectVariant) => {
if (!schema) return;
if (variant.schemaName) setStage({ kind: 'form', variant, values: defaultsFor(schema, variant) });
else void run(variant, {});
};
const handlePickVariant = useCallback(
(variant: ObjectVariant) => {
if (!schema || !resolved) return;
if (!variant.schemaName) {
executeAction(schema, resolved.objectName, variant.name, {}, setState);
} else {
const fieldsDef = schema.fields[variant.schemaName!];
const defaults = fieldsDef?.defaults ? { ...fieldsDef.defaults } : {};
setState({ kind: 'form', variant, formData: defaults });
}
},
[schema, resolved],
);
const handleSubmitForm = useCallback(
async (variant: ObjectVariant, formData: Record<string, unknown>) => {
if (!schema || !resolved) return;
setState({ kind: 'submitting', variant });
const fields = variant.schemaName ? schema.fields[variant.schemaName] : null;
const payload: Record<string, unknown> = { '@type': variant.name };
if (fields) {
for (const [name, def] of Object.entries(fields.properties)) {
if (def.update === 'serverSet') continue;
if (name in formData) {
const isSecret =
def.type.type === 'string' && (def.type.format === 'secret' || def.type.format === 'secretText');
if (isSecret && formData[name] === SECRET_MASK) continue;
payload[name] = formData[name];
}
}
}
try {
const accountId = getAccountId(resolved.objectName);
const responses = await jmapSet(resolved.objectName, accountId, {
create: { 'action-0': payload },
});
const setResponse = responses[responses.length - 1];
const setResult = setResponse[1] as unknown as JmapSetResponse;
if (setResult.created && setResult.created['action-0']) {
const created = setResult.created['action-0'];
const noisyKeys = new Set(['id', 'blobId']);
const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k));
if (extraKeys.length > 0) {
const extra: Record<string, unknown> = {};
for (const k of extraKeys) extra[k] = created[k];
setState({ kind: 'result', variant, props: extra, inputData: formData });
} else {
setState({ kind: 'result', variant, props: null, inputData: formData });
}
} else if (setResult.notCreated && setResult.notCreated['action-0']) {
const err = setResult.notCreated['action-0'];
setState({
kind: 'error',
variant,
error: (err as { description?: string }).description ?? t('actions.actionFailed', 'Action failed.'),
});
} else {
setState({ kind: 'result', variant, props: null, inputData: formData });
}
} catch (e) {
setState({
kind: 'error',
variant,
error: e instanceof Error ? e.message : t('actions.actionFailed', 'Action failed.'),
});
}
},
[schema, resolved, t],
);
// inbuxa: `?action=<name>` from the command palette opens that action. One
// with options shows its form; one without asks first, since here a click
// on it runs it at once. Read while rendering, so it also works when the
// palette is used on this page; the effect then takes it out of the address.
// `?action=<name>` from the command palette opens that action, asking
// first for one without options. It's read while rendering so it also
// works when the palette is used on this page; the effect then takes it
// out of the address.
const requested = searchParams.get('action');
const [openedFromPalette, setOpenedFromPalette] = useState<string | null>(null);
if (requested !== openedFromPalette) {
setOpenedFromPalette(requested);
const variant = requested && sch?.type === 'multiple' ? sch.variants.find((v) => v.name === requested) : undefined;
if (schema && variant && canCreate && hasPermission(`action${variant.name}`)) {
if (variant.schemaName) {
const defaults = schema.fields[variant.schemaName]?.defaults;
setState({ kind: 'form', variant, formData: defaults ? { ...defaults } : {} });
} else {
setState({ kind: 'confirm', variant });
}
const [handled, setHandled] = useState<string | null>(null);
if (requested !== handled) {
setHandled(requested);
const variant = requested ? variants?.find((v) => v.name === requested) : undefined;
if (schema && variant && canRun && allowed(variant.name)) {
setStage(
variant.schemaName
? { kind: 'form', variant, values: defaultsFor(schema, variant) }
: { kind: 'confirm', variant },
);
}
}
useEffect(() => {
@@ -189,16 +114,55 @@ export function ActionPage({ viewName }: ActionPageProps) {
);
}, [requested, setSearchParams]);
if (!schema || !resolved || !sch) return null;
if (!schema || !object || !variants) return null;
const toList = () => setStage({ kind: 'pick' });
if (state.kind === 'confirm') {
return (
<div className="mx-auto max-w-xl space-y-4 pt-8">
<Card>
switch (stage.kind) {
case 'pick': {
const list = schema.lists[viewName] ?? schema.lists[object.objectName];
const subtitle = list?.subtitle ?? object.objectType.description;
const other = t('actions.otherGroup', 'Other');
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">{list?.title ?? t('actions.title', 'Actions')}</h1>
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
</div>
<div className="space-y-6">
{[...groupActions(variants, allowed, other)].map(([group, members]) => (
<div key={group}>
<h3 className="text-sm font-medium text-muted-foreground mb-2">{group}</h3>
<div className="grid gap-2 sm:grid-cols-2">
{members.map((variant) => (
<Button
key={variant.name}
variant="outline"
className="justify-between h-auto py-3 px-4"
disabled={!canRun}
onClick={() => open(variant)}
>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
<span>{splitLabel(variant.label, other).name}</span>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Button>
))}
</div>
</div>
))}
</div>
</div>
);
}
case 'confirm':
return (
<Narrow>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
{state.variant.label}
{stage.variant.label}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@@ -206,236 +170,105 @@ export function ActionPage({ viewName }: ActionPageProps) {
{t('actions.confirmRun', 'This runs on the server as soon as you choose Run. It has no options.')}
</p>
<div className="flex gap-2">
<Button onClick={() => handlePickVariant(state.variant)}>{t('actions.run', 'Run')}</Button>
<Button variant="outline" onClick={() => setState({ kind: 'pick' })}>
<Button onClick={() => open(stage.variant)}>{t('actions.run', 'Run')}</Button>
<Button variant="outline" onClick={toList}>
{t('actions.backToActions', 'Back to actions')}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
</Narrow>
);
if (state.kind === 'pick') {
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
case 'form':
return (
<OptionsForm
schema={schema}
variant={stage.variant}
initial={stage.values}
onRun={(values) => void run(stage.variant, values)}
onBack={toList}
/>
);
case 'running':
return (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">
{t('actions.executing', 'Executing {{label}}...', { label: stage.variant.label })}
</p>
</div>
<div className="space-y-6">
{Array.from(groups.entries()).map(([group, groupVariants]) => (
<div key={group}>
<h3 className="text-sm font-medium text-muted-foreground mb-2">{group}</h3>
<div className="grid gap-2 sm:grid-cols-2">
{groupVariants.map((v) => {
const colonIdx = v.label.indexOf(':');
const actionName = colonIdx > 0 ? v.label.slice(colonIdx + 1).trim() : v.label;
return (
<Button
key={v.name}
variant="outline"
className="justify-between h-auto py-3 px-4"
disabled={!canCreate}
onClick={() => handlePickVariant(v)}
>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
<span>{actionName}</span>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Button>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
);
if (state.kind === 'form') {
return (
<ActionFormView
schema={schema}
variant={state.variant}
formData={state.formData}
onSubmit={(data) => handleSubmitForm(state.variant, data)}
onBack={() => setState({ kind: 'pick' })}
/>
);
}
case 'done':
return (
<ActionDone
schema={schema}
variant={stage.variant}
values={stage.values}
returned={stage.returned}
onAnother={toList}
/>
);
if (state.kind === 'submitting') {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">
{t('actions.executing', 'Executing {{label}}...', { label: state.variant.label })}
</p>
</div>
);
}
if (state.kind === 'result') {
return (
<ActionResultView
schema={schema}
variant={state.variant}
props={state.props}
inputData={state.inputData}
onNewAction={() => setState({ kind: 'pick' })}
/>
);
}
if (state.kind === 'error') {
return (
<div className="mx-auto max-w-xl space-y-4 pt-8">
<Card>
case 'failed':
return (
<Narrow>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-3">
<CircleAlert className="h-5 w-5 text-destructive shrink-0" />
<div>
<p className="font-medium">{t('actions.failedTitle', 'Action failed')}</p>
<p className="text-sm text-muted-foreground mt-1">{state.error}</p>
<p className="text-sm text-muted-foreground mt-1">{stage.error}</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setState({ kind: 'pick' })}>
<Button variant="outline" onClick={toList}>
{t('actions.backToActions', 'Back to actions')}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return null;
}
async function executeAction(
schema: Schema,
objectName: string,
variantName: string,
formData: Record<string, unknown>,
setState: React.Dispatch<React.SetStateAction<ActionState>>,
) {
const variant = { name: variantName, label: variantName } as ObjectVariant;
setState({ kind: 'submitting', variant });
try {
const accountId = getAccountId(objectName);
const payload: Record<string, unknown> = { '@type': variantName, ...formData };
const responses = await jmapSet(objectName, accountId, {
create: { 'action-0': payload },
});
const setResponse = responses[responses.length - 1];
const setResult = setResponse[1] as unknown as JmapSetResponse;
const sch = resolveSchema(schema, objectName);
const realVariant =
sch?.type === 'multiple' ? (sch.variants.find((v) => v.name === variantName) ?? variant) : variant;
if (setResult.created && setResult.created['action-0']) {
const created = setResult.created['action-0'];
const noisyKeys = new Set(['id', 'blobId']);
const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k));
if (extraKeys.length > 0) {
const extra: Record<string, unknown> = {};
for (const k of extraKeys) extra[k] = created[k];
setState({ kind: 'result', variant: realVariant, props: extra, inputData: formData });
} else {
setState({ kind: 'result', variant: realVariant, props: null, inputData: formData });
}
} else if (setResult.notCreated && setResult.notCreated['action-0']) {
const err = setResult.notCreated['action-0'];
setState({
kind: 'error',
variant: realVariant,
error: (err as { description?: string }).description ?? i18n.t('actions.actionFailed', 'Action failed.'),
});
} else {
setState({ kind: 'result', variant: realVariant, props: null, inputData: formData });
}
} catch (e) {
setState({
kind: 'error',
variant,
error: e instanceof Error ? e.message : i18n.t('actions.actionFailed', 'Action failed.'),
});
</Narrow>
);
}
}
function ActionFormView({
schema,
variant,
formData: initialData,
onSubmit,
onBack,
}: {
function Narrow({ children }: { children: ReactNode }) {
return (
<div className="mx-auto max-w-xl space-y-4 pt-8">
<Card>{children}</Card>
</div>
);
}
interface OptionsFormProps {
schema: Schema;
variant: ObjectVariant;
formData: Record<string, unknown>;
onSubmit: (data: Record<string, unknown>) => void;
initial: Record<string, unknown>;
onRun: (values: Record<string, unknown>) => void;
onBack: () => void;
}) {
}
/** An action's options, from its variant's form, with only the fields an admin sets. */
function OptionsForm({ schema, variant, initial, onRun, onBack }: OptionsFormProps) {
const { t } = useTranslation();
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
const [values, setValues] = useState(initial);
const [errors, setErrors] = useState<Record<string, string>>({});
if (!variant.schemaName) return null;
const fields = schema.fields[variant.schemaName];
const fields = variant.schemaName ? schema.fields[variant.schemaName] : undefined;
const form = resolveVariantForm(schema, '', '', variant.schemaName);
if (!fields || !form) return null;
const sections = inputSections(fields, form);
const mutableSections = form.sections
.map((section) => ({
...section,
fields: section.fields.filter((ff) => {
const def = fields.properties[ff.name];
return def && def.update === 'mutable';
}),
}))
.filter((s) => s.fields.length > 0);
const handleChange = (fieldName: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
setErrors((prev) => {
const copy = { ...prev };
delete copy[fieldName];
return copy;
});
const change = (name: string, value: unknown) => {
setValues((prev) => ({ ...prev, [name]: value }));
setErrors(({ [name]: _cleared, ...rest }) => (void _cleared, rest));
};
const handleSubmit = () => {
const newErrors: Record<string, string> = {};
for (const section of mutableSections) {
for (const ff of section.fields) {
const def = fields.properties[ff.name];
if (!def) continue;
const isRequired =
(def.type.type === 'string' ||
def.type.type === 'number' ||
def.type.type === 'enum' ||
def.type.type === 'objectId') &&
!('nullable' in def.type && def.type.nullable);
if (isRequired) {
const val = formData[ff.name];
if (val === undefined || val === null || val === '') {
newErrors[ff.name] = t('form.required', 'This field is required.');
}
}
}
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit(formData);
const submit = () => {
const missing = missingInputs(sections, fields, values, t('form.required', 'This field is required.'));
if (Object.keys(missing).length > 0) setErrors(missing);
else onRun(values);
};
return (
@@ -447,8 +280,8 @@ function ActionFormView({
<h1 className="text-xl font-semibold">{variant.label}</h1>
</div>
{mutableSections.map((section, si) => (
<Card key={si}>
{sections.map((section, i) => (
<Card key={i}>
{section.title && (
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
@@ -456,22 +289,18 @@ function ActionFormView({
)}
<CardContent className={section.title ? 'pt-0' : ''}>
<div className="space-y-4">
{section.fields.map((ff) => {
const def = fields.properties[ff.name];
if (!def) return null;
return (
<FieldWidget
key={ff.name}
formField={ff}
field={def}
value={formData[ff.name]}
onChange={(v) => handleChange(ff.name, v)}
readOnly={false}
schema={schema}
error={errors[ff.name]}
/>
);
})}
{section.fields.map((ff) => (
<FieldWidget
key={ff.name}
formField={ff}
field={fields.properties[ff.name]}
value={values[ff.name]}
onChange={(v) => change(ff.name, v)}
readOnly={false}
schema={schema}
error={errors[ff.name]}
/>
))}
</div>
</CardContent>
</Card>
@@ -481,7 +310,7 @@ function ActionFormView({
<Button variant="outline" onClick={onBack}>
{t('common.cancel', 'Cancel')}
</Button>
<Button onClick={handleSubmit}>
<Button onClick={submit}>
<Zap className="mr-2 h-4 w-4" />
{t('actions.execute', 'Execute')}
</Button>
@@ -490,24 +319,20 @@ function ActionFormView({
);
}
function ActionResultView({
schema,
variant,
props,
inputData,
onNewAction,
}: {
interface ActionDoneProps {
schema: Schema;
variant: ObjectVariant;
props: Record<string, unknown> | null;
inputData: Record<string, unknown>;
onNewAction: () => void;
}) {
const { t } = useTranslation();
const mergedData = useMemo(() => ({ ...inputData, ...(props ?? {}) }), [inputData, props]);
const hasData = variant.schemaName && Object.keys(mergedData).length > 0;
values: Record<string, unknown>;
returned: Record<string, unknown> | null;
onAnother: () => void;
}
const visibleFields = useMemo(() => new Set(Object.keys(mergedData)), [mergedData]);
/** The action ran: its options and what the server sent back, shown through the variant's own view. */
function ActionDone({ schema, variant, values, returned, onAnother }: ActionDoneProps) {
const { t } = useTranslation();
const data = useMemo(() => ({ ...values, ...returned }), [values, returned]);
const shown = useMemo(() => new Set(Object.keys(data)), [data]);
const hasData = !!variant.schemaName && shown.size > 0;
return (
<div className="space-y-6 pt-4">
@@ -526,16 +351,16 @@ function ActionResultView({
schema={schema}
objectName={variant.schemaName}
viewName={variant.schemaName}
data={mergedData}
visibleFields={visibleFields}
data={data}
visibleFields={shown}
/>
)}
{/* inbuxa: "Explain this" for a spam verdict (ai-explain EX-17) */}
{variant.name === 'ClassifySpam' && props && (
{/* "Explain this" for a spam verdict (ai-explain EX-17). */}
{variant.name === 'ClassifySpam' && returned && (
<ExplainButton
subject={() => spamVerdictSubject(props)}
title={t('explain.spamVerdictTitle', 'Spam verdict: {{result}}', { result: String(props.result ?? '') })}
subject={() => spamVerdictSubject(returned)}
title={t('explain.spamVerdictTitle', 'Spam verdict: {{result}}', { result: String(returned.result ?? '') })}
/>
)}
@@ -547,7 +372,7 @@ function ActionResultView({
</Card>
)}
<Button onClick={onNewAction}>
<Button onClick={onAnother}>
<Zap className="mr-2 h-4 w-4" />
{t('actions.executeAnother', 'Execute another action')}
</Button>
+144
View File
@@ -0,0 +1,144 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import type { Field, Fields, Form, ObjectVariant } from '@/types/schema';
import { actionPayload, groupActions, inputSections, missingInputs, readActionResult, splitLabel } from './actions';
const variants: ObjectVariant[] = [
{ name: 'ReloadSettings', label: 'Reload: Server settings' },
{ name: 'ReloadTls', label: 'Reload: TLS certificates' },
{ name: 'ClassifySpam', label: 'Spam Filter: Classify a message', schemaName: 'x:SpamClassify' },
{ name: 'Odd', label: ':starts with a colon' },
{ name: 'Plain', label: 'No group' },
{ name: 'Hidden', label: 'Reload: Hidden' },
];
describe('splitLabel', () => {
it('splits "Group: Action" at the first colon', () => {
expect(splitLabel('Reload: Server settings', 'Other')).toEqual({ group: 'Reload', name: 'Server settings' });
expect(splitLabel('A: b: c', 'Other')).toEqual({ group: 'A', name: 'b: c' });
expect(splitLabel('No group', 'Other')).toEqual({ group: 'Other', name: 'No group' });
expect(splitLabel(':x', 'Other')).toEqual({ group: 'Other', name: ':x' });
});
});
describe('groupActions', () => {
it('groups the allowed actions in schema order', () => {
const groups = groupActions(variants, (name) => name !== 'Hidden', 'Other');
expect([...groups].map(([g, list]) => [g, list.map((v) => v.name)])).toEqual([
['Reload', ['ReloadSettings', 'ReloadTls']],
['Spam Filter', ['ClassifySpam']],
['Other', ['Odd', 'Plain']],
]);
});
});
const field = (type: Record<string, unknown>, update: Field['update'] = 'mutable') =>
({ description: '', type, update }) as unknown as Field;
const fields: Fields = {
properties: {
message: field({ type: 'string', format: 'text' }),
password: field({ type: 'string', format: 'secret' }),
note: field({ type: 'string', format: 'secretText' }),
ip: field({ type: 'string', format: 'ipAddress' }),
optional: field({ type: 'string', nullable: true }),
count: field({ type: 'number' }),
level: field({ type: 'enum' }),
tls: field({ type: 'boolean' }),
when: field({ type: 'utcDateTime' }),
score: field({ type: 'number' }, 'serverSet'),
fixed: field({ type: 'string' }, 'immutable'),
},
};
describe('actionPayload', () => {
it('sends the action type and the inputs the admin set, not server results or unchanged secrets', () => {
expect(
actionPayload('ClassifySpam', fields, {
message: 'raw',
password: '****',
note: 'new secret',
score: 5,
tls: false,
extra: 'not in schema',
}),
).toEqual({ '@type': 'ClassifySpam', message: 'raw', note: 'new secret', tls: false });
});
it('is only the type for an action without options', () => {
expect(actionPayload('ReloadSettings', null, { anything: 1 })).toEqual({ '@type': 'ReloadSettings' });
});
});
describe('readActionResult', () => {
it('returns what the server added, without ids', () => {
expect(readActionResult({ created: { 'action-0': { id: 'x', blobId: 'b', score: 3, result: 'ham' } } })).toEqual({
kind: 'done',
returned: { score: 3, result: 'ham' },
});
expect(readActionResult({ created: { 'action-0': { id: 'x' } } })).toEqual({ kind: 'done', returned: null });
});
it("returns the server's reason for a refusal, if it gave one", () => {
expect(readActionResult({ notCreated: { 'action-0': { type: 'forbidden', description: 'Busy' } } })).toEqual({
kind: 'refused',
reason: 'Busy',
});
expect(readActionResult({ notCreated: { 'action-0': { type: 'forbidden' } } })).toEqual({
kind: 'refused',
reason: null,
});
});
it('counts an answer about neither as done', () => {
expect(readActionResult({})).toEqual({ kind: 'done', returned: null });
expect(readActionResult(undefined)).toEqual({ kind: 'done', returned: null });
});
});
describe('the options form', () => {
const form: Form = {
sections: [
{
title: 'Input',
fields: [
{ name: 'message', label: 'Message' },
{ name: 'optional', label: 'Optional' },
{ name: 'count', label: 'Count' },
{ name: 'level', label: 'Level' },
{ name: 'tls', label: 'TLS' },
{ name: 'when', label: 'When' },
{ name: 'ghost', label: 'Not in the schema' },
],
},
{
title: 'Results',
fields: [
{ name: 'score', label: 'Score' },
{ name: 'fixed', label: 'Fixed' },
],
},
],
};
const sections = inputSections(fields, form);
it('shows only the inputs, dropping sections of results', () => {
expect(sections.map((s) => [s.title, s.fields.map((f) => f.name)])).toEqual([
['Input', ['message', 'optional', 'count', 'level', 'tls', 'when']],
]);
});
it('requires text, numbers and choices that are not optional', () => {
expect(missingInputs(sections, fields, { message: '', count: null, tls: undefined }, 'Required')).toEqual({
message: 'Required',
count: 'Required',
level: 'Required',
});
expect(missingInputs(sections, fields, { message: 'm', count: 0, level: 'x' }, 'Required')).toEqual({});
});
});
+103
View File
@@ -0,0 +1,103 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Server actions (`x:Action`): each variant is an action, run by creating
* one. These are the page's rules apart from the page.
*/
import { SECRET_MASK } from '@/lib/jmapUtils';
import type { JmapSetError } from '@/types/jmap';
import type { Fields, Form, FormSection, ObjectVariant } from '@/types/schema';
/** The id an action is created under; the answer comes back under it. */
export const CREATE_ID = 'action-0';
/** Labels read "Group: Action"; a label without a group goes under `other`. */
export function splitLabel(label: string, other: string): { group: string; name: string } {
const colon = label.indexOf(':');
return colon > 0
? { group: label.slice(0, colon).trim(), name: label.slice(colon + 1).trim() }
: { group: other, name: label };
}
/** The actions the admin may run, by group, in the order the schema lists them. */
export function groupActions(
variants: ObjectVariant[],
allowed: (name: string) => boolean,
other: string,
): Map<string, ObjectVariant[]> {
const groups = new Map<string, ObjectVariant[]>();
for (const variant of variants) {
if (!allowed(variant.name)) continue;
const { group } = splitLabel(variant.label, other);
groups.set(group, [...(groups.get(group) ?? []), variant]);
}
return groups;
}
/**
* What creating the action sends: its type, and each option the admin set.
* Results the server fills in stay out, and so does a secret still showing
* its mask, which means it wasn't changed.
*/
export function actionPayload(
type: string,
fields: Fields | null,
values: Record<string, unknown>,
): Record<string, unknown> {
const payload: Record<string, unknown> = { '@type': type };
for (const [name, def] of Object.entries(fields?.properties ?? {})) {
if (def.update === 'serverSet' || !(name in values)) continue;
const secret = def.type.type === 'string' && (def.type.format === 'secret' || def.type.format === 'secretText');
if (secret && values[name] === SECRET_MASK) continue;
payload[name] = values[name];
}
return payload;
}
export type ActionResult =
/** What the server sent back besides ids, or null when that was nothing. */
{ kind: 'done'; returned: Record<string, unknown> | null } | { kind: 'refused'; reason: string | null };
const IDS = new Set(['id', 'blobId']);
/** Reads the answer to creating the action. An answer that mentions neither outcome counts as done. */
export function readActionResult(result: Record<string, unknown> | undefined): ActionResult {
const created = (result?.created as Record<string, Record<string, unknown>> | null | undefined)?.[CREATE_ID];
const refused = (result?.notCreated as Record<string, JmapSetError> | null | undefined)?.[CREATE_ID];
if (!created && refused) return { kind: 'refused', reason: refused.description ?? null };
const returned = Object.fromEntries(Object.entries(created ?? {}).filter(([key]) => !IDS.has(key)));
return { kind: 'done', returned: Object.keys(returned).length > 0 ? returned : null };
}
/** The form's sections cut down to the options an admin sets; sections of results drop out. */
export function inputSections(fields: Fields, form: Form): FormSection[] {
return form.sections
.map((section) => ({
...section,
fields: section.fields.filter((ff) => fields.properties[ff.name]?.update === 'mutable'),
}))
.filter((section) => section.fields.length > 0);
}
/** Text, numbers, choices and object references must be filled in unless optional. */
const CHECKED = new Set(['string', 'number', 'enum', 'objectId']);
export function missingInputs(
sections: FormSection[],
fields: Fields,
values: Record<string, unknown>,
required: string,
): Record<string, string> {
const errors: Record<string, string> = {};
for (const ff of sections.flatMap((s) => s.fields)) {
const type = fields.properties[ff.name]?.type;
if (!type || !CHECKED.has(type.type) || ('nullable' in type && type.nullable)) continue;
const value = values[ff.name];
if (value === undefined || value === null || value === '') errors[ff.name] = required;
}
return errors;
}