diff --git a/backend/modules.go b/backend/modules.go index 1be83bd8f..61c1bc433 100644 --- a/backend/modules.go +++ b/backend/modules.go @@ -207,7 +207,16 @@ func initModules(db *gorm.DB, cfg *config) *modules { notificationsMod := notifications.NewModule(db, auditMod.Logger(), joblease.New(db), env.Int("NOTIFICATIONS_READ_RETENTION_DAYS", 30, false), env.Int("NOTIFICATIONS_RETENTION_DAYS", 365, false)) - soarMod := soar.NewModule(db, agentClient, signer, cipher, socAIClient, notificationsMod.Producer(), tenantLister) + // Incidents module is built early so its usecase can back the SOAR incident + // executor. Its own deps (db + mail + config + alerts + audit) are already + // available at this point. + incidentsMod := incidents.NewModule( + db, + incidents.NewIncidentMailer(mailMod.Service(), configMod.Store()), + incidents.NewAlertsGatewayFromUsecase(alertsMod.GetAlertUsecase()), + auditMod.Logger(), + ) + soarMod := soar.NewModule(db, agentClient, signer, cipher, socAIClient, notificationsMod.Producer(), incidentsMod.GetIncidentUsecase(), mailMod.Service(), tenantLister) eventProcessingMod := eventprocessing.NewModule(db, events, auditMod.Logger(), cfg.playgroundBaseURL, cfg.internalKey) alertsMod.SetCorrelationResolver(eventProcessingMod) @@ -266,12 +275,6 @@ func initModules(db *gorm.DB, cfg *config) *modules { socAIMod := socai.NewModule(cfg.socAIBaseURL, cfg.internalKey, cipher, env.String("INTEGRATIONS_CONFIG_DIR", "/workdir/pipeline", false), env.String("UPDATES_DIR", "/updates", false), aiQuota, joblease.New(db)) - incidentsMod := incidents.NewModule( - db, - incidents.NewIncidentMailer(mailMod.Service(), configMod.Store()), - incidents.NewAlertsGatewayFromUsecase(alertsMod.GetAlertUsecase()), - auditMod.Logger(), - ) adauditMod := adaudit.NewModule(db) storageMod := storage.NewModule(events, env.String("CLICKHOUSE_CONFIG_DIR", "/clickhouse-conf", false)) threatintelMod := threatintel.NewModule( diff --git a/backend/modules/soar/executor/conditional.go b/backend/modules/soar/executor/conditional.go new file mode 100644 index 000000000..f5fb36752 --- /dev/null +++ b/backend/modules/soar/executor/conditional.go @@ -0,0 +1,123 @@ +package executor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/tidwall/gjson" + + "github.com/utmstack/utmstack/backend/modules/soar/domain" +) + +// Conditional evaluates a list of predicates against the execution's merged +// context bag and returns success only when all of them are true (AND). A +// failing predicate returns an error so the dispatcher routes the flow down +// the node's onError branch — no bespoke edge kind needed. +// ponytail: reuses domain.FilterType and gjson (already vendored via variable +// + execution interpolation); OnSuccess/OnError already model the true/false +// exits of a conditional. +type Conditional struct{} + +func NewConditional() *Conditional { return &Conditional{} } + +func (Conditional) Type() string { return "conditional" } + +type conditionalParams struct { + Conditions []domain.FilterType `json:"conditions"` +} + +func (c *Conditional) Execute(_ context.Context, exec *domain.SoarExecution) (json.RawMessage, error) { + var p conditionalParams + if len(exec.Params) > 0 { + if err := json.Unmarshal(exec.Params, &p); err != nil { + return nil, fmt.Errorf("soar conditional: params: %w", err) + } + } + if len(p.Conditions) == 0 { + return nil, errors.New("soar conditional: at least one condition is required") + } + src := string(exec.Context) + if src == "" { + src = "{}" + } + for _, cond := range p.Conditions { + if !evaluateFilter(src, cond) { + exec.Result = fmt.Sprintf("condition failed: %s %s %v", cond.Field, cond.Operator, cond.Value) + return nil, errors.New(exec.Result) + } + } + exec.Result = "all conditions matched" + return nil, nil +} + +func evaluateFilter(src string, cond domain.FilterType) bool { + val := gjson.Get(src, cond.Field) + switch cond.Operator { + case domain.OperatorExists: + return val.Exists() + case domain.OperatorNotExists: + return !val.Exists() + } + got := val.String() + switch cond.Operator { + case domain.OperatorIS: + return got == asString(cond.Value) + case domain.OperatorISNot: + return got != asString(cond.Value) + case domain.OperatorContains: + return strings.Contains(got, asString(cond.Value)) + case domain.OperatorNotContains: + return !strings.Contains(got, asString(cond.Value)) + case domain.OperatorStartWith: + return strings.HasPrefix(got, asString(cond.Value)) + case domain.OperatorNotStartWith: + return !strings.HasPrefix(got, asString(cond.Value)) + case domain.OperatorEndsWith: + return strings.HasSuffix(got, asString(cond.Value)) + case domain.OperatorNotEndsWith: + return !strings.HasSuffix(got, asString(cond.Value)) + case domain.OperatorIsOneOf: + return oneOf(asStringSlice(cond.Value), got) + case domain.OperatorIsNotOneOf: + return !oneOf(asStringSlice(cond.Value), got) + } + return false +} + +func asString(v any) string { + switch t := v.(type) { + case string: + return t + case nil: + return "" + default: + b, _ := json.Marshal(t) + return string(b) + } +} + +func asStringSlice(v any) []string { + switch t := v.(type) { + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, x := range t { + out = append(out, asString(x)) + } + return out + } + return nil +} + +func oneOf(hay []string, needle string) bool { + for _, h := range hay { + if h == needle { + return true + } + } + return false +} diff --git a/backend/modules/soar/executor/conditional_test.go b/backend/modules/soar/executor/conditional_test.go new file mode 100644 index 000000000..98ad17f26 --- /dev/null +++ b/backend/modules/soar/executor/conditional_test.go @@ -0,0 +1,44 @@ +package executor + +import ( + "context" + "encoding/json" + "testing" + + "github.com/utmstack/utmstack/backend/modules/soar/domain" +) + +func TestConditional_AllMatchTakesSuccessBranch(t *testing.T) { + c := NewConditional() + exec := &domain.SoarExecution{ + Kind: domain.NodeKindExecutor, + Context: json.RawMessage(`{"alert":{"severity":"high","tags":["prod","edr"]}}`), + Params: json.RawMessage(`{"conditions":[ + {"field":"alert.severity","operator":"IS","value":"high"}, + {"field":"alert.tags","operator":"CONTAINS","value":"edr"} + ]}`), + } + if _, err := c.Execute(context.Background(), exec); err != nil { + t.Fatalf("expected success, got %v", err) + } +} + +func TestConditional_MismatchRoutesToOnError(t *testing.T) { + c := NewConditional() + exec := &domain.SoarExecution{ + Kind: domain.NodeKindExecutor, + Context: json.RawMessage(`{"alert":{"severity":"low"}}`), + Params: json.RawMessage(`{"conditions":[{"field":"alert.severity","operator":"IS","value":"high"}]}`), + } + if _, err := c.Execute(context.Background(), exec); err == nil { + t.Fatal("expected error so the dispatcher takes the onError branch") + } +} + +func TestConditional_MissingParamsFails(t *testing.T) { + c := NewConditional() + exec := &domain.SoarExecution{Context: json.RawMessage(`{}`)} + if _, err := c.Execute(context.Background(), exec); err == nil { + t.Fatal("expected error when no conditions are configured") + } +} diff --git a/backend/modules/soar/executor/incident.go b/backend/modules/soar/executor/incident.go new file mode 100644 index 000000000..79d9887b5 --- /dev/null +++ b/backend/modules/soar/executor/incident.go @@ -0,0 +1,88 @@ +package executor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/tidwall/gjson" + + incidentsdomain "github.com/utmstack/utmstack/backend/modules/incidents/domain" + incidentsdto "github.com/utmstack/utmstack/backend/modules/incidents/dto" + soardomain "github.com/utmstack/utmstack/backend/modules/soar/domain" +) + +// IncidentOpener is the narrow slice of the incidents usecase that the SOAR +// incident executor consumes. Keeps this package free of the broader incidents +// import and lets tests swap in a fake. +type IncidentOpener interface { + Create(ctx context.Context, userEmail string, req incidentsdto.CreateIncidentRequest) (*incidentsdomain.Incident, error) +} + +// Incident opens an incident and links the alert that fired the flow. Params +// are just name + description — alert identity (id/name/severity) comes from +// the exec's built-in AlertID and the context bag populated by the dispatcher. +// ponytail: reuses incidents.CreateIncidentRequest verbatim — no shadow DTO. +type Incident struct{ client IncidentOpener } + +func NewIncident(c IncidentOpener) *Incident { return &Incident{client: c} } + +func (Incident) Type() string { return "incident" } + +type incidentParams struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +func (i *Incident) Execute(ctx context.Context, exec *soardomain.SoarExecution) (json.RawMessage, error) { + if i.client == nil { + return nil, errors.New("soar incident: client not configured") + } + var p incidentParams + if len(exec.Params) > 0 { + if err := json.Unmarshal(exec.Params, &p); err != nil { + return nil, fmt.Errorf("soar incident: params: %w", err) + } + } + name := strings.TrimSpace(p.Name) + if name == "" { + return nil, errors.New("soar incident: name is required") + } + if strings.TrimSpace(exec.AlertID) == "" { + return nil, errors.New("soar incident: no alert linked to this execution") + } + + src := string(exec.Context) + if src == "" { + src = "{}" + } + alertName := gjson.Get(src, "alert.name").String() + if alertName == "" { + alertName = exec.AlertID + } + severity := gjson.Get(src, "alert.severity").String() + if severity == "" { + severity = "Low" + } + + req := incidentsdto.CreateIncidentRequest{ + IncidentName: name, + AlertList: []incidentsdto.AlertLinkItem{{ + AlertID: exec.AlertID, + AlertName: alertName, + AlertSeverity: severity, + }}, + } + if desc := strings.TrimSpace(p.Description); desc != "" { + req.IncidentDescription = &desc + } + + inc, err := i.client.Create(ctx, "", req) + if err != nil { + return nil, fmt.Errorf("soar incident: create: %w", err) + } + exec.Result = fmt.Sprintf("opened incident %s: %s", inc.ID.String(), inc.Name) + return nil, nil +} diff --git a/backend/modules/soar/executor/mail.go b/backend/modules/soar/executor/mail.go new file mode 100644 index 000000000..bd7272df7 --- /dev/null +++ b/backend/modules/soar/executor/mail.go @@ -0,0 +1,72 @@ +package executor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + maildomain "github.com/utmstack/utmstack/backend/internal/mail/domain" + soardomain "github.com/utmstack/utmstack/backend/modules/soar/domain" +) + +// MailSender is the narrow slice of the mail service that the SOAR mail +// executor consumes. Keeps this package free of the broader mail import and +// lets tests swap in a fake. +type MailSender interface { + SendMail(ctx context.Context, to []string, cc []string, subject, body string, attachments []maildomain.Attatchment) error +} + +// Mail sends an email via the tenant's configured SMTP settings. Params are +// to, cc, subject, body — all $()-templates are already interpolated by the +// dispatcher before Execute runs. +// ponytail: no attachments (YAGNI); comma-split addresses, no header parser. +type Mail struct{ client MailSender } + +func NewMail(c MailSender) *Mail { return &Mail{client: c} } + +func (Mail) Type() string { return "mail" } + +type mailParams struct { + To string `json:"to"` + CC string `json:"cc,omitempty"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +func (m *Mail) Execute(ctx context.Context, exec *soardomain.SoarExecution) (json.RawMessage, error) { + if m.client == nil { + return nil, errors.New("soar mail: client not configured") + } + var p mailParams + if len(exec.Params) > 0 { + if err := json.Unmarshal(exec.Params, &p); err != nil { + return nil, fmt.Errorf("soar mail: params: %w", err) + } + } + to := splitAddresses(p.To) + if len(to) == 0 { + return nil, errors.New("soar mail: at least one recipient is required") + } + if strings.TrimSpace(p.Subject) == "" { + return nil, errors.New("soar mail: subject is required") + } + cc := splitAddresses(p.CC) + if err := m.client.SendMail(ctx, to, cc, p.Subject, p.Body, nil); err != nil { + return nil, fmt.Errorf("soar mail: send: %w", err) + } + exec.Result = fmt.Sprintf("sent email to %d recipient(s): %s", len(to), p.Subject) + return nil, nil +} + +func splitAddresses(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if addr := strings.TrimSpace(part); addr != "" { + out = append(out, addr) + } + } + return out +} diff --git a/backend/modules/soar/executor/selectexec.go b/backend/modules/soar/executor/selectexec.go deleted file mode 100644 index 57e54accd..000000000 --- a/backend/modules/soar/executor/selectexec.go +++ /dev/null @@ -1,61 +0,0 @@ -package executor - -import ( - "context" - "encoding/json" - "errors" - "fmt" - - "github.com/tidwall/gjson" - - "github.com/utmstack/utmstack/backend/modules/soar/domain" -) - -// Select is a lightweight enrichment: it composes an output object by pulling -// gjson paths out of the current context bag. Handy when downstream nodes want -// a subset (or a renamed subset) of ancestor data without dragging in a jq -// dependency. Meant for kind=enrichment. -// ponytail: gjson is already vendored (used in variable + execution -// interpolation); output built via encoding/json — no new dep. -type Select struct{} - -func NewSelect() *Select { return &Select{} } - -func (Select) Type() string { return "select" } - -type selectParams struct { - Fields map[string]string `json:"fields"` -} - -func (s *Select) Execute(_ context.Context, exec *domain.SoarExecution) (json.RawMessage, error) { - if exec.Kind != domain.NodeKindEnrichment { - return nil, errors.New("soar select: kind must be enrichment") - } - var p selectParams - if len(exec.Params) > 0 { - if err := json.Unmarshal(exec.Params, &p); err != nil { - return nil, fmt.Errorf("soar select: params: %w", err) - } - } - if len(p.Fields) == 0 { - return json.RawMessage(`{}`), nil - } - src := string(exec.Context) - if src == "" { - src = "{}" - } - out := make(map[string]json.RawMessage, len(p.Fields)) - for name, path := range p.Fields { - val := gjson.Get(src, path) - if !val.Exists() { - continue - } - out[name] = json.RawMessage(val.Raw) - } - raw, err := json.Marshal(out) - if err != nil { - return nil, fmt.Errorf("soar select: marshal: %w", err) - } - exec.Result = string(raw) - return raw, nil -} diff --git a/backend/modules/soar/module.go b/backend/modules/soar/module.go index ea5f62140..b0f01ca91 100644 --- a/backend/modules/soar/module.go +++ b/backend/modules/soar/module.go @@ -42,6 +42,8 @@ func NewModule( cipher *secret.Cipher, llm executor.LLMStreamer, notifier executor.Notifier, + incidentOpener executor.IncidentOpener, + mailSender executor.MailSender, tenantLister func(context.Context) ([]string, error), ) *Module { flowsSrc := env.String("SOAR_FLOWS_SRC_DIR", "/utmstack/soar", false) @@ -59,9 +61,9 @@ func NewModule( variableUC := usecase.NewVariableUsecase(variableRepo, cipher) registry := executor.Registry{ - "shell": executor.NewShell(agentClient), - "http": executor.NewHTTP(), - "select": executor.NewSelect(), + "shell": executor.NewShell(agentClient), + "http": executor.NewHTTP(), + "conditional": executor.NewConditional(), } if llm != nil { registry["llm_enrich"] = executor.NewLLMEnrich(llm) @@ -70,6 +72,12 @@ func NewModule( if notifier != nil { registry["notify"] = executor.NewNotify(notifier) } + if incidentOpener != nil { + registry["incident"] = executor.NewIncident(incidentOpener) + } + if mailSender != nil { + registry["mail"] = executor.NewMail(mailSender) + } dispatcher := usecase.NewDispatcher(executionRepo, flowRunRepo, flowStore, variableUC, registry) diff --git a/frontend/src/features/soar/components/ConditionalParamsEditor.tsx b/frontend/src/features/soar/components/ConditionalParamsEditor.tsx new file mode 100644 index 000000000..fe04be1b6 --- /dev/null +++ b/frontend/src/features/soar/components/ConditionalParamsEditor.tsx @@ -0,0 +1,131 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Plus, X } from 'lucide-react' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { ALERT_FIELDS } from '../lib/alert-fields' +import { enrichmentAncestors } from '../lib/ancestors' +import { + SOAR_MULTI_VALUE_OPERATORS, + SOAR_NO_VALUE_OPERATORS, + SOAR_OPERATORS, + type FlowCondition, + type FlowNode, + type SoarOperator, +} from '../types/soar.types' + +const SELECT = + 'h-8 rounded-md border border-input bg-background px-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + +interface Props { + nodeId: string + nodes: Record + params: unknown + readOnly?: boolean + onChange: (params: { conditions: FlowCondition[] }) => void +} + +// ponytail: reuses SoarOperator + native ; success/fail routing rides +// on the DAG's existing green/red handles — no bespoke branch state. +export function ConditionalParamsEditor({ nodeId, nodes, params, readOnly, onChange }: Props) { + const { t } = useTranslation() + const conditions = normalize(params) + const listId = `soar-cond-fields-${nodeId}` + const suggestions = useMemo(() => buildSuggestions(nodes, nodeId), [nodes, nodeId]) + + const setAt = (i: number, patch: Partial) => + onChange({ conditions: conditions.map((c, k) => (k === i ? { ...c, ...patch } : c)) }) + + const valueStr = (c: FlowCondition) => + Array.isArray(c.value) ? c.value.join(', ') : c.value == null ? '' : String(c.value) + + return ( +
+

{t('soar.editor.canvas.conditionalHint')}

+ + {suggestions.map((s) => ( + +
+ {conditions.map((c, i) => ( +
+ setAt(i, { field: e.target.value })} + placeholder="alert.severity" + className="h-8 min-w-[160px] flex-1 font-mono text-[11px]" + /> + + {!SOAR_NO_VALUE_OPERATORS.includes(c.operator) && ( + setAt(i, { value: e.target.value })} + placeholder={ + SOAR_MULTI_VALUE_OPERATORS.includes(c.operator) + ? t('soar.editor.valueList') + : t('soar.editor.value') + } + className="h-8 min-w-[140px] flex-1 font-mono text-[11px]" + /> + )} + {!readOnly && ( + + )} +
+ ))} +
+ {!readOnly && ( + + )} +
+ ) +} + +function normalize(params: unknown): FlowCondition[] { + if (!params || typeof params !== 'object') return [] + const list = (params as { conditions?: unknown }).conditions + return Array.isArray(list) ? (list as FlowCondition[]) : [] +} + +// Suggestion list for the field : alert.* plus every reachable +// enrichment ancestor's declared fields. Paths match the runtime context bag. +function buildSuggestions(nodes: Record, currentNodeId: string): string[] { + const out: string[] = ALERT_FIELDS.map((af) => `alert.${af.field}`) + for (const a of enrichmentAncestors(nodes, currentNodeId)) { + if (a.fields.length) out.push(...a.fields.map((f) => `${a.nodeId}.${f}`)) + else out.push(`${a.nodeId}.`) + } + return out +} diff --git a/frontend/src/features/soar/components/FlowCanvas.tsx b/frontend/src/features/soar/components/FlowCanvas.tsx index 407c48654..ab1334f92 100644 --- a/frontend/src/features/soar/components/FlowCanvas.tsx +++ b/frontend/src/features/soar/components/FlowCanvas.tsx @@ -20,9 +20,10 @@ import '@xyflow/react/dist/style.css' import { ChevronLeft, ChevronRight, PanelLeft, PanelRight } from 'lucide-react' import { cn } from '@/shared/lib/utils' import { useTheme } from '@/shared/hooks/useTheme' -import type { FlowNode, NodeKind } from '../types/soar.types' +import type { FlowCondition, FlowNode, NodeKind } from '../types/soar.types' import { NodePalette } from './NodePalette' import { NodeInspector } from './NodeInspector' +import { TriggerInspector } from './TriggerInspector' import { DAGNode } from './nodes/DAGNode' import { TriggerNode } from './nodes/TriggerNode' @@ -32,8 +33,10 @@ const TRIGGER_ID = '__trigger__' interface Props { roots: string[] nodes: Record + conditions: FlowCondition[] readOnly?: boolean onChange: (patch: { roots: string[]; nodes: Record }) => void + onConditionsChange: (c: FlowCondition[]) => void } /** Node-red style DAG editor for a SOAR flow. Nodes come from the flow's @@ -47,7 +50,7 @@ export function FlowCanvas(props: Props) { ) } -function FlowCanvasInner({ roots, nodes, readOnly, onChange }: Props) { +function FlowCanvasInner({ roots, nodes, conditions, readOnly, onChange, onConditionsChange }: Props) { const { t } = useTranslation() const wrapperRef = useRef(null) const { screenToFlowPosition } = useReactFlow() @@ -56,7 +59,7 @@ function FlowCanvasInner({ roots, nodes, readOnly, onChange }: Props) { // Layout positions are stashed by node id so they survive re-derivation // whenever the flow model updates. Fresh nodes get a top-down default. const layoutRef = useRef>({}) - const [selectedId, setSelectedId] = useState(null) + const [selectedId, setSelectedId] = useState(TRIGGER_ID) const [paletteOpen, setPaletteOpen] = useState(true) const [inspectorOpen, setInspectorOpen] = useState(true) @@ -70,6 +73,7 @@ function FlowCanvasInner({ roots, nodes, readOnly, onChange }: Props) { type: 'trigger', position: posFor(TRIGGER_ID, { x: 120, y: 0 }), data: {}, + selected: selectedId === TRIGGER_ID, draggable: !readOnly, deletable: false, }, @@ -134,8 +138,10 @@ function FlowCanvasInner({ roots, nodes, readOnly, onChange }: Props) { for (const c of changes) { if (c.type === 'position' && c.position) layoutRef.current[c.id] = c.position if (c.type === 'select') { - if (c.selected && c.id !== TRIGGER_ID) setSelectedId(c.id) - else if (!c.selected && selectedId === c.id) setSelectedId(null) + if (c.selected) { + setSelectedId(c.id) + if (c.id === TRIGGER_ID) setInspectorOpen(true) + } else if (!c.selected && selectedId === c.id) setSelectedId(null) } } onNodesChange(changes) @@ -333,9 +339,9 @@ function FlowCanvasInner({ roots, nodes, readOnly, onChange }: Props) { } return ( -
+
{paletteOpen ? ( -
+
- {selected && selectedId ? ( + {selectedId === TRIGGER_ID ? ( + inspectorOpen ? ( +
+ + +
+ ) : ( + setInspectorOpen(true)} /> + ) + ) : selected && selectedId ? ( inspectorOpen ? (
(k: K, v: FlowFormState[K]) => setForm((f) => ({ ...f, [k]: v })) + useEffect(() => { + clearHttpBodyErrors() + return () => clearHttpBodyErrors() + }, [flow?.relPath]) + const toCode = () => { setYaml(flowFormToYaml(form)) setMode('code') @@ -82,6 +79,39 @@ export function FlowEditor({ toast.error(t('soar.editor.nodesRequired', 'Add at least one node and connect it to the trigger.')) return } + for (const [id, n] of Object.entries(input.nodes)) { + if (n.executor === 'http') { + const url = (n.params as { url?: string } | undefined)?.url ?? '' + if (!isValidHttpUrl(url)) { + toast.error(t('soar.editor.httpUrlInvalid', { id })) + return + } + } + if (n.executor === 'incident') { + const name = (n.params as { name?: string } | undefined)?.name?.trim() ?? '' + if (!name) { + toast.error(t('soar.editor.incidentNameRequired', { id })) + return + } + } + if (n.executor === 'mail') { + const mp = (n.params as { to?: string; subject?: string } | undefined) ?? {} + const to = (mp.to ?? '').split(',').map((s) => s.trim()).filter(Boolean) + if (to.length === 0) { + toast.error(t('soar.editor.mailToRequired', { id })) + return + } + if (!(mp.subject ?? '').trim()) { + toast.error(t('soar.editor.mailSubjectRequired', { id })) + return + } + } + } + const bodyErr = firstHttpBodyError() + if (bodyErr) { + toast.error(t('soar.editor.httpBodyInvalid', { id: bodyErr.nodeId, error: bodyErr.err })) + return + } setBusy(true) try { if (creating) await soarFlowsService.create(input) @@ -142,7 +172,17 @@ export function FlowEditor({

- {creating ? t('soar.editor.createTitle') : flow?.name} + {form.name.trim() || (creating ? t('soar.editor.createTitle') : (flow?.name ?? ''))} + {!readOnly && ( + + )} {readOnly && ( {t('soar.system')} @@ -179,70 +219,29 @@ export function FlowEditor({

) : ( -
- {/* Identity + flow-level knobs */} -
-
-
- - set('name', e.target.value)} - placeholder={t('soar.editor.namePlaceholder')} - className="text-base font-semibold" - /> -
-
- - set('maxDepth', Number(e.target.value) || 50)} - className="text-sm" - /> -
-
-
- -