diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index 651e323..919fc87 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -30,6 +30,7 @@ import { Calendar } from '@/components/ui/calendar'; import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { ExpressionEditor } from '@/components/expression/ExpressionEditor'; +import { EventPicker } from '@/features/events/EventPicker'; import type { ExpressionHints } from '@/components/expression/simple'; import { OtpAuthField } from '@/components/forms/OtpAuthField'; import { SievepadButton } from '@/components/forms/SievepadButton'; @@ -1684,6 +1685,17 @@ function SetField({ scalarType, value, onChange, readOnly, schema, minItems, max /> ); case 'enum': + // inbuxa: 649 event types get a grouped picker with common picks (settings-reorg). + if (scalarType.enumName === 'EventType') { + return ( + onChange(arrayToSetObject(newItems))} + readOnly={readOnly} + /> + ); + } return ( ; } + if (viewName === 'x:WebHook') { + return ; + } return ; } diff --git a/src/features/events/EventPicker.tsx b/src/features/events/EventPicker.tsx new file mode 100644 index 0000000..559723d --- /dev/null +++ b/src/features/events/EventPicker.tsx @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: choosing events out of 649, grouped by category with a switch for + * the whole group, a search that reads the explanations too, and a few + * common picks (settings-reorg, second wave). Used wherever a form asks for + * a set of EventType: webhooks and tracers. + */ + +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronRight, Search } from 'lucide-react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import type { EnumVariant } from '@/types/schema'; +import { cn } from '@/lib/utils'; +import { COMMON_PICKS, groupEvents } from './eventGroups'; + +export function EventPicker({ + variants, + items, + onChange, + readOnly, +}: { + variants: EnumVariant[]; + items: string[]; + onChange: (items: string[]) => void; + readOnly: boolean; +}) { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const [open, setOpen] = useState>(new Set()); + const chosen = useMemo(() => new Set(items), [items]); + const known = useMemo(() => new Set(variants.map((v) => v.name)), [variants]); + const groups = useMemo(() => groupEvents(variants, search), [variants, search]); + + const set = (names: string[], on: boolean) => { + const next = new Set(chosen); + for (const n of names) { + if (on) next.add(n); + else next.delete(n); + } + onChange([...next]); + }; + + return ( +
+
+

+ {t('eventPicker.common', 'Common picks')} +

+
+ {COMMON_PICKS.map((p) => { + const names = p.events.filter((e) => known.has(e)); + const all = names.length > 0 && names.every((n) => chosen.has(n)); + return ( + + ); + })} +
+
+ +
+ + setSearch(e.target.value)} + placeholder={t('eventPicker.search', 'Search events, e.g. bounce, ban, certificate')} + className="h-8" + /> + + {t('eventPicker.chosen', '{{count}} chosen', { count: items.length })} + +
+ +
    + {groups.map((g) => { + const names = g.events.map((e) => e.name); + const count = names.filter((n) => chosen.has(n)).length; + const expanded = open.has(g.category) || search.trim() !== ''; + return ( +
  • +
    + set(names, v === true)} + aria-label={g.name} + /> + +
    + {expanded && ( +
      + {g.events.map((e) => ( +
    • + +
    • + ))} +
    + )} +
  • + ); + })} + {groups.length === 0 && ( +
  • + {t('eventPicker.none', 'No events match.')} +
  • + )} +
+
+ ); +} diff --git a/src/features/events/WebhooksPage.tsx b/src/features/events/WebhooksPage.tsx new file mode 100644 index 0000000..f73479c --- /dev/null +++ b/src/features/events/WebhooksPage.tsx @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: Monitoring › Webhooks, with what the server sends above the list + * (settings-reorg, second wave): the body's shape, how the signature works, + * and a way to see one arrive. + */ + +import { useTranslation } from 'react-i18next'; +import { Webhook } from 'lucide-react'; +import { DynamicList } from '@/components/lists/DynamicList'; +import { EXAMPLE_BODY } from './eventGroups'; + +export function WebhooksPage({ viewName }: { viewName: string }) { + const { t } = useTranslation(); + return ( +
+
+
+ +

{t('webhooks.title', 'What a webhook receives')}

+
+

+ {t( + 'webhooks.body', + 'A POST with a JSON body holding one or more of the events you chose, batched at most once per throttle interval:', + )} +

+
{EXAMPLE_BODY}
+

+ {t( + 'webhooks.signature', + 'With a signature key set, each request carries X-Signature: the HMAC-SHA256 of the raw body under that key, in base64. Compute it on your side and compare before trusting the request.', + )} +

+

+ {t( + 'webhooks.try', + 'To see one arrive, choose “Failed sign-ins and bans” and sign in once with a wrong password. Failed deliveries are retried until “Discard after”.', + )} +

+
+ +
+ ); +} diff --git a/src/features/events/eventGroups.test.ts b/src/features/events/eventGroups.test.ts new file mode 100644 index 0000000..6b84121 --- /dev/null +++ b/src/features/events/eventGroups.test.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { categoryName, groupEvents } from './eventGroups'; + +const variants = [ + { name: 'auth.failed', label: 'Authentication failed', explanation: 'A wrong password.' }, + { name: 'auth.success', label: 'Authentication succeeded' }, + { name: 'delivery.delivered', label: 'Message delivered' }, + { name: 'security.ip-blocked', label: 'Blocked IP address', explanation: 'A ban.' }, +]; + +describe('event groups', () => { + it('group by category, named in words', () => { + const g = groupEvents(variants); + expect(g.map((x) => x.name)).toEqual(['Outgoing delivery', 'Security', 'Sign-in']); + expect(g.find((x) => x.category === 'auth')?.events).toHaveLength(2); + expect(categoryName('unknown-cat')).toBe('unknown-cat'); + }); + + it('search names, labels, explanations and category names', () => { + expect(groupEvents(variants, 'ban').flatMap((g) => g.events.map((e) => e.name))).toEqual(['security.ip-blocked']); + expect(groupEvents(variants, 'wrong password').flatMap((g) => g.events.map((e) => e.name))).toEqual([ + 'auth.failed', + ]); + expect(groupEvents(variants, 'sign-in').flatMap((g) => g.events)).toHaveLength(2); + }); +}); diff --git a/src/features/events/eventGroups.ts b/src/features/events/eventGroups.ts new file mode 100644 index 0000000..cece6e1 --- /dev/null +++ b/src/features/events/eventGroups.ts @@ -0,0 +1,160 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: the server's 649 event types, grouped for picking (settings-reorg, + * second wave). Names are "category.event"; the category is the group. + */ + +import type { EnumVariant } from '@/types/schema'; + +const CATEGORY_NAMES: Record = { + acme: 'Certificates (ACME)', + ai: 'AI', + arc: 'ARC', + auth: 'Sign-in', + calendar: 'Calendar', + cluster: 'Cluster', + dane: 'DANE', + delivery: 'Outgoing delivery', + dkim: 'DKIM', + dmarc: 'DMARC', + dns: 'DNS', + eval: 'Expressions', + http: 'HTTP', + imap: 'IMAP', + 'incoming-report': 'Reports received', + iprev: 'Reverse DNS', + jmap: 'JMAP', + limit: 'Limits', + 'mail-auth': 'Mail authentication', + 'manage-sieve': 'ManageSieve', + 'message-ingest': 'Incoming messages', + milter: 'Milters', + 'mta-hook': 'MTA hooks', + 'mta-sts': 'MTA-STS', + network: 'Network', + 'outgoing-report': 'Reports sent', + pop3: 'POP3', + 'push-subscription': 'Push', + queue: 'Queue', + registry: 'Settings', + resource: 'Resources', + scim: 'SCIM', + security: 'Security', + server: 'Server', + sieve: 'Sieve', + smtp: 'SMTP', + spam: 'Spam filter', + spf: 'SPF', + store: 'Storage', + 'task-manager': 'Tasks', + telemetry: 'Telemetry and alerts', + tls: 'TLS', + 'tls-rpt': 'TLS reporting', + 'web-dav': 'WebDAV', +}; + +export function categoryOf(event: string): string { + return event.split('.')[0]; +} + +export function categoryName(category: string): string { + return CATEGORY_NAMES[category] ?? category; +} + +export interface EventGroup { + category: string; + name: string; + events: EnumVariant[]; +} + +/** Events grouped by category, groups in a readable order, optionally filtered by a search. */ +export function groupEvents(variants: EnumVariant[], search = ''): EventGroup[] { + const q = search.trim().toLowerCase(); + const groups = new Map(); + for (const v of variants) { + if ( + q && + !v.name.toLowerCase().includes(q) && + !v.label.toLowerCase().includes(q) && + !(v.explanation ?? '').toLowerCase().includes(q) && + !categoryName(categoryOf(v.name)).toLowerCase().includes(q) + ) { + continue; + } + const c = categoryOf(v.name); + groups.set(c, [...(groups.get(c) ?? []), v]); + } + return [...groups.entries()] + .map(([category, events]) => ({ category, name: categoryName(category), events })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export interface CommonPick { + id: string; + name: string; + hint: string; + events: string[]; +} + +export const COMMON_PICKS: CommonPick[] = [ + { + id: 'delivery', + name: 'Delivery results', + hint: 'A message was delivered, delayed or bounced.', + events: [ + 'delivery.delivered', + 'delivery.dsn-success', + 'delivery.dsn-temp-fail', + 'delivery.dsn-perm-fail', + 'delivery.double-bounce', + ], + }, + { + id: 'security', + name: 'Failed sign-ins and bans', + hint: 'Wrong passwords, and addresses the server blocked.', + events: [ + 'auth.failed', + 'auth.too-many-attempts', + 'security.authentication-ban', + 'security.abuse-ban', + 'security.scan-ban', + 'security.ip-blocked', + ], + }, + { + id: 'alerts', + name: 'Alerts', + hint: 'An alert fired (Monitoring › Alerts), as an event or an email.', + events: ['telemetry.alert-event', 'telemetry.alert-message'], + }, + { + id: 'spam', + name: 'Spam verdicts', + hint: 'An incoming message was filed as spam or as not spam.', + events: ['message-ingest.spam', 'message-ingest.ham'], + }, + { + id: 'certificates', + name: 'Certificates', + hint: 'A certificate was issued, or ordering one failed.', + events: ['acme.order-completed', 'acme.order-invalid', 'acme.error'], + }, +]; + +/** The shape of what the server posts, for the example (trc/src/serializers/json.rs). */ +export const EXAMPLE_BODY = `{ + "events": [ + { + "id": "17906227430001234", + "createdAt": "2026-09-28T20:15:24Z", + "type": "auth.failed", + "data": { "…": "the event's own details, which vary by event" } + } + ] +}`;