diff --git a/src/components/bootstrap/BootstrapWizard.tsx b/src/components/bootstrap/BootstrapWizard.tsx index 86a0775..094643b 100644 --- a/src/components/bootstrap/BootstrapWizard.tsx +++ b/src/components/bootstrap/BootstrapWizard.tsx @@ -1,274 +1,132 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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>({}); - const [originalData, setOriginalData] = useState>({}); - const [currentPage, setCurrentPage] = useState(0); + const [values, setValues] = useState>({}); + const [step, setStep] = 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 [error, setError] = useState(null); + const [generated, setGenerated] = 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]); - + // 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> | 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[] | 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): 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 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, page: number | null) => { + setFieldErrors(errors); + if (page !== null) setStep(page); + }; - 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); + 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 {t('form.loadingSchema', 'Loading schema...')}; + } + if (loading) { + return {t('bootstrap.loadingSetup', 'Loading setup...')}; + } - 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 = {}; - 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 ( - -
- - {t('form.loadingSchema', 'Loading schema...')} -
-
- ); - } - - if (loading) { - return ( - -
- - {t('bootstrap.loadingSetup', 'Loading setup...')} -
-
- ); - } - - if (successExtras !== null) { - return ( - - + - + ); } - if (sections.length === 0) { + if (pages.length === 0) { return ( - +
{t('bootstrap.emptyForm', 'Setup form is empty. The server did not return any bootstrap fields.')}
-
+ ); } - 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 ( - +

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

@@ -342,30 +226,38 @@ export function BootstrapWizard() {

- +
+ {pages.map((_, i) => ( +
+ ))} +
- {generalError && ( + {error && (
-

{generalError}

+

{error}

)} - {current.title && ( + {page.title && ( - {current.title} + {page.title} )} - +
+ {/* Enterprise-only options stay out of setup. */} - {current.fields.map(({ formField, field }) => ( + {page.fields.map(({ formField, field }) => ( 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() {
- -
- {t('bootstrap.stepOf', 'Step {{current}} of {{total}}', { - current: currentPage + 1, - total: sections.length, - })} -
- {isLastPage ? ( - ) : ( - )}
- + ); } -function WizardShell({ children }: { children: React.ReactNode }) { +/** The page around every step: the logo bar and a centered column. */ +function Shell({ children }: { children: ReactNode }) { return (
@@ -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 ( -
- {Array.from({ length: total }, (_, i) => ( -
- ))} -
+ +
+ + {children} +
+
); } -function SuccessScreen({ - extras, - fields, - labelsByName, - onCopy, - copied, -}: { - extras: Record; - fields: Fields; - labelsByName: Map; - 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 (
@@ -466,7 +335,7 @@ function SuccessScreen({

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

- {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({

- {hasCredentials && ( + {values.length > 0 && ( - {entries.map(([key, value]) => { - const field = fields.properties[key]; - const label = labelsByName.get(key) ?? field?.description?.split('\n')[0] ?? key; - return ( -
-
{label}
-
- {value} - -
+ {values.map(([key, value]) => ( +
+
{label(key)}
+
+ {value} +
- ); - })} +
+ ))} )} @@ -511,13 +376,4 @@ function SuccessScreen({ ); } -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; diff --git a/src/components/bootstrap/wizard.test.ts b/src/components/bootstrap/wizard.test.ts new file mode 100644 index 0000000..389f05b --- /dev/null +++ b/src/components/bootstrap/wizard.test.ts @@ -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, more: Partial = {}) => + ({ 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'); + }); +}); diff --git a/src/components/bootstrap/wizard.ts b/src/components/bootstrap/wizard.ts new file mode 100644 index 0000000..af8e2de --- /dev/null +++ b/src/components/bootstrap/wizard.ts @@ -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; + 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, + required: string, +): Record { + const errors: Record = {}; + 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, 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(); + 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 } + | { 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 | undefined): SetOutcome { + const updated = result?.updated as Record | null> | null | undefined; + const notUpdated = result?.notUpdated as Record | 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, 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(); + 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; +} diff --git a/src/features/actions/ActionPage.tsx b/src/features/actions/ActionPage.tsx index 7d7e24c..ce237aa 100644 --- a/src/features/actions/ActionPage.tsx +++ b/src/features/actions/ActionPage.tsx @@ -1,180 +1,105 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 } - | { kind: 'submitting'; variant: ObjectVariant } - | { - kind: 'result'; - variant: ObjectVariant; - props: Record | null; - inputData: Record; - } - | { kind: 'error'; variant: ObjectVariant; error: string }; + | { kind: 'form'; variant: ObjectVariant; values: Record } + | { kind: 'running'; variant: ObjectVariant } + | { kind: 'done'; variant: ObjectVariant; values: Record; returned: Record | 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({ kind: 'pick' }); + const [stage, setStage] = useState({ 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(); - 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) => { + 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) => { - if (!schema || !resolved) return; - setState({ kind: 'submitting', variant }); - - const fields = variant.schemaName ? schema.fields[variant.schemaName] : null; - const payload: Record = { '@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 = {}; - 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=` 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=` 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(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(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 ( -
- + 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 ( +
+
+

{list?.title ?? t('actions.title', 'Actions')}

+ {subtitle &&

{subtitle}

} +
+
+ {[...groupActions(variants, allowed, other)].map(([group, members]) => ( +
+

{group}

+
+ {members.map((variant) => ( + + ))} +
+
+ ))} +
+
+ ); + } + + case 'confirm': + return ( + - {state.variant.label} + {stage.variant.label} @@ -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.')}

- - +
-
-
- ); - } + + ); - if (state.kind === 'pick') { - return ( -
-
-

{title}

- {subtitle &&

{subtitle}

} + case 'form': + return ( + void run(stage.variant, values)} + onBack={toList} + /> + ); + + case 'running': + return ( +
+ +

+ {t('actions.executing', 'Executing {{label}}...', { label: stage.variant.label })} +

-
- {Array.from(groups.entries()).map(([group, groupVariants]) => ( -
-

{group}

-
- {groupVariants.map((v) => { - const colonIdx = v.label.indexOf(':'); - const actionName = colonIdx > 0 ? v.label.slice(colonIdx + 1).trim() : v.label; - return ( - - ); - })} -
-
- ))} -
-
- ); - } + ); - if (state.kind === 'form') { - return ( - handleSubmitForm(state.variant, data)} - onBack={() => setState({ kind: 'pick' })} - /> - ); - } + case 'done': + return ( + + ); - if (state.kind === 'submitting') { - return ( -
- -

- {t('actions.executing', 'Executing {{label}}...', { label: state.variant.label })} -

-
- ); - } - - if (state.kind === 'result') { - return ( - setState({ kind: 'pick' })} - /> - ); - } - - if (state.kind === 'error') { - return ( -
- + case 'failed': + return ( +

{t('actions.failedTitle', 'Action failed')}

-

{state.error}

+

{stage.error}

-
-
-
- ); - } - - return null; -} - -async function executeAction( - schema: Schema, - objectName: string, - variantName: string, - formData: Record, - setState: React.Dispatch>, -) { - const variant = { name: variantName, label: variantName } as ObjectVariant; - setState({ kind: 'submitting', variant }); - - try { - const accountId = getAccountId(objectName); - const payload: Record = { '@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 = {}; - 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.'), - }); + + ); } } -function ActionFormView({ - schema, - variant, - formData: initialData, - onSubmit, - onBack, -}: { +function Narrow({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +interface OptionsFormProps { schema: Schema; variant: ObjectVariant; - formData: Record; - onSubmit: (data: Record) => void; + initial: Record; + onRun: (values: Record) => 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>(initialData); + const [values, setValues] = useState(initial); const [errors, setErrors] = useState>({}); - 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 = {}; - 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({

{variant.label}

- {mutableSections.map((section, si) => ( - + {sections.map((section, i) => ( + {section.title && ( {section.title} @@ -456,22 +289,18 @@ function ActionFormView({ )}
- {section.fields.map((ff) => { - const def = fields.properties[ff.name]; - if (!def) return null; - return ( - handleChange(ff.name, v)} - readOnly={false} - schema={schema} - error={errors[ff.name]} - /> - ); - })} + {section.fields.map((ff) => ( + change(ff.name, v)} + readOnly={false} + schema={schema} + error={errors[ff.name]} + /> + ))}
@@ -481,7 +310,7 @@ function ActionFormView({ - @@ -490,24 +319,20 @@ function ActionFormView({ ); } -function ActionResultView({ - schema, - variant, - props, - inputData, - onNewAction, -}: { +interface ActionDoneProps { schema: Schema; variant: ObjectVariant; - props: Record | null; - inputData: Record; - onNewAction: () => void; -}) { - const { t } = useTranslation(); - const mergedData = useMemo(() => ({ ...inputData, ...(props ?? {}) }), [inputData, props]); - const hasData = variant.schemaName && Object.keys(mergedData).length > 0; + values: Record; + returned: Record | 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 (
@@ -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 && ( 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({ )} - diff --git a/src/features/actions/actions.test.ts b/src/features/actions/actions.test.ts new file mode 100644 index 0000000..b5c3367 --- /dev/null +++ b/src/features/actions/actions.test.ts @@ -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, 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({}); + }); +}); diff --git a/src/features/actions/actions.ts b/src/features/actions/actions.ts new file mode 100644 index 0000000..1633056 --- /dev/null +++ b/src/features/actions/actions.ts @@ -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 { + const groups = new Map(); + 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, +): Record { + const payload: Record = { '@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 | 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 | undefined): ActionResult { + const created = (result?.created as Record> | null | undefined)?.[CREATE_ID]; + const refused = (result?.notCreated as Record | 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, + required: string, +): Record { + const errors: Record = {}; + 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; +}