main
vue 349 lines 11.3 KB
Raw
1 <template>
2 <n-form ref="formRef" :model="form" :rules label-placement="top" class="flex flex-col gap-1">
3 <n-form-item label="Name" path="name">
4 <n-input v-model:value="form.name" placeholder="e.g. SOC team Slack #alerts" :maxlength="128" show-count />
5 </n-form-item>
6
7 <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
8 <n-form-item label="Trigger" path="trigger">
9 <n-select v-model:value="form.trigger" :options="triggerOptions" />
10 </n-form-item>
11
12 <n-form-item label="Minimum severity" path="min_severity">
13 <n-select v-model:value="form.min_severity" :options="severityOptions" />
14 </n-form-item>
15 </div>
16
17 <!--
18 Channel is now Shuffle-only — the SMTP path was removed when we
19 consolidated email delivery into Shuffle's catalog (Outlook,
20 Gmail, SendGrid, SMTP-via-Shuffle, etc. all live there). Field
21 stays in the form for clarity but is fixed to a single value.
22 -->
23 <n-form-item label="Channel">
24 <n-select v-model:value="form.channel" :options="channelOptions" disabled />
25 <template v-if="!fieldErrors.channel" #feedback>
26 Email is direct SMTP via CoPilot's deployment config. Shuffle proxies to 3,000+ integrations through a
27 customer's authenticated Shuffle org.
28 </template>
29 </n-form-item>
30
31 <n-form-item label="Shuffle integration" path="shuffle_integration_id">
32 <n-select
33 v-model:value="form.shuffle_integration_id"
34 :options="integrationOptions"
35 placeholder="Pick a Shuffle org for this customer"
36 :loading="loadingIntegrations"
37 @update:value="onIntegrationChange"
38 />
39 <template
40 v-if="!fieldErrors.shuffle_integration_id && !integrationOptions.length && !loadingIntegrations"
41 #feedback
42 >
43 No Shuffle integrations configured for this customer yet — go to the
44 <strong>Shuffle integrations</strong>
45 tab to add one first.
46 </template>
47 </n-form-item>
48
49 <n-form-item label="Shuffle app" path="shuffle_app_id">
50 <n-select
51 v-model:value="form.shuffle_app_id"
52 :options="appOptions"
53 placeholder="Pick an authenticated app"
54 :loading="loadingApps"
55 :disabled="!form.shuffle_integration_id || loadingApps"
56 filterable
57 @update:value="onAppChange"
58 />
59 <template v-if="showShuffleAppsFetchError" #feedback>
60 <div class="text-error">Couldn't fetch apps from Shuffle: {{ appsError }}</div>
61 </template>
62 </n-form-item>
63
64 <n-form-item label="Destination hint" path="destination">
65 <n-input
66 v-model:value="form.destination"
67 placeholder="e.g. #soc-alerts, soc@example.com, @user-id"
68 type="text"
69 />
70 <template v-if="!fieldErrors.destination" #feedback>
71 Free-form — gets prepended to the outgoing message as a
72 <code>Send to &lt;destination&gt;: …</code>
73 hint so the Shuffle app agent knows where to deliver. Channel name for Slack, email for Outlook / Gmail,
74 handle for chat apps, etc.
75 </template>
76 </n-form-item>
77 <n-form-item label="Custom message template (optional)" path="format_template" :show-feedback="false">
78 <n-input
79 v-model:value="form.format_template"
80 type="textarea"
81 :autosize="{ minRows: 4, maxRows: 12 }"
82 placeholder="Leave empty to use the default. Substitutions: {{customer_code}} {{alert_id}} {{alert_name}} {{severity}} {{summary}} {{report_url}}"
83 />
84 </n-form-item>
85
86 <n-form-item>
87 <n-checkbox v-model:checked="form.enabled">Enabled</n-checkbox>
88 </n-form-item>
89
90 <div class="flex justify-end gap-2">
91 <n-button @click="$emit('close')">Cancel</n-button>
92 <n-button type="primary" :loading="submitting" @click="submit">
93 {{ editing ? "Save changes" : "Create route" }}
94 </n-button>
95 </div>
96 </n-form>
97 </template>
98
99 <script setup lang="ts">
100 import type { FormInst, FormRules } from "naive-ui"
101 import type {
102 NotificationRoute,
103 NotificationRoutePayload,
104 NotificationSeverity,
105 NotificationTrigger,
106 ShuffleApp,
107 ShuffleIntegration
108 } from "@/types/notifications.d"
109 import { NButton, NCheckbox, NForm, NFormItem, NInput, NSelect, useMessage } from "naive-ui"
110 import { computed, onBeforeMount, reactive, ref } from "vue"
111 import Api from "@/api"
112 import { getApiErrorMessage } from "@/utils"
113
114 const props = defineProps<{
115 customerCode: string
116 editingRoute: NotificationRoute | null
117 }>()
118
119 const emit = defineEmits<{
120 (e: "submitted"): void
121 (e: "close"): void
122 }>()
123
124 const message = useMessage()
125 const formRef = ref<FormInst | null>(null)
126 const submitting = ref(false)
127
128 const editing = computed(() => props.editingRoute !== null)
129 type FeedbackField = "channel" | "destination" | "min_severity" | "shuffle_app_id" | "shuffle_integration_id"
130
131 // Channel is hard-coded to "shuffle" — the SMTP path was removed.
132 // Existing routes loaded via the editing path may have legacy values
133 // in the DB; we still use the route's channel as-is on edit (fall
134 // back to "shuffle" for new routes).
135 const fieldErrors = reactive<Partial<Record<FeedbackField, string>>>({})
136
137 const form = reactive<NotificationRoutePayload>({
138 name: props.editingRoute?.name ?? "",
139 trigger: props.editingRoute?.trigger ?? ("investigation_complete" as NotificationTrigger),
140 channel: props.editingRoute?.channel ?? "shuffle",
141 destination: props.editingRoute?.destination ?? "",
142 min_severity: props.editingRoute?.min_severity ?? ("Medium" as NotificationSeverity),
143 format_template: props.editingRoute?.format_template ?? "",
144 enabled: props.editingRoute?.enabled ?? true,
145 shuffle_integration_id: props.editingRoute?.shuffle_integration_id ?? null,
146 shuffle_app_id: props.editingRoute?.shuffle_app_id ?? null,
147 shuffle_app_name: props.editingRoute?.shuffle_app_name ?? null
148 })
149
150 // Trigger is a single fixed value today (`investigation_complete`).
151 // Hidden from the UI — set programmatically on the form. Will become
152 // a select again when we add more dispatch event types (analyst-review
153 // hooks, IOC-enrichment alerts, scheduled-sweep findings).
154 const triggerOptions = [
155 { label: "Every investigation completes", value: "investigation_complete" },
156 { label: "Critical / High severity only", value: "severity_critical_or_high" }
157 ]
158
159 // Single-option list — keeps the n-select rendering consistent with
160 // the rest of the form even though there's nothing to pick. If we
161 // ever add a second channel back (e.g. raw webhook), this is the line
162 // to extend.
163 const channelOptions = [{ label: "Shuffle (Slack / Teams / Outlook / 3,000+ apps)", value: "shuffle" }]
164
165 const severityOptions = [
166 { label: "Critical (only)", value: "Critical" },
167 { label: "High and above", value: "High" },
168 { label: "Medium and above", value: "Medium" },
169 { label: "Low and above", value: "Low" },
170 { label: "Informational and above (everything)", value: "Informational" }
171 ]
172
173 // Shuffle integrations + apps state. Integrations are fetched on form
174 // open; apps are fetched lazily when an integration is picked. Both
175 // short-circuit on edit so we don't blank a route's existing values.
176 const integrations = ref<ShuffleIntegration[]>([])
177 const loadingIntegrations = ref(false)
178 const apps = ref<ShuffleApp[]>([])
179 const loadingApps = ref(false)
180 const appsError = ref<string | null>(null)
181
182 const integrationOptions = computed(() =>
183 integrations.value
184 .filter(i => i.enabled)
185 .map(i => ({
186 label: `${i.display_name} (${i.shuffle_org_id.slice(0, 8)}…)`,
187 value: i.id
188 }))
189 )
190
191 const appOptions = computed(() =>
192 apps.value.map(a => ({
193 label: a.name,
194 value: a.id
195 }))
196 )
197
198 const showShuffleAppsFetchError = computed(
199 () =>
200 !fieldErrors.shuffle_app_id &&
201 Boolean(form.shuffle_integration_id) &&
202 appOptions.value.length === 0 &&
203 !loadingApps.value &&
204 Boolean(appsError.value)
205 )
206
207 function clearFieldError(field: FeedbackField) {
208 delete fieldErrors[field]
209 }
210
211 function createFieldError(field: FeedbackField, message: string) {
212 fieldErrors[field] = message
213 return new Error(message)
214 }
215
216 async function loadIntegrations() {
217 loadingIntegrations.value = true
218 try {
219 const res = await Api.notifications.listShuffleIntegrations(props.customerCode)
220 if (res.data.success) {
221 integrations.value = res.data.integrations
222 }
223 } catch (err: unknown) {
224 message.error(getApiErrorMessage(err as never) || "Failed to load Shuffle integrations")
225 } finally {
226 loadingIntegrations.value = false
227 }
228 }
229
230 async function loadApps(integrationId: number) {
231 loadingApps.value = true
232 appsError.value = null
233 try {
234 const res = await Api.notifications.listShuffleApps(props.customerCode, integrationId)
235 if (res.data.success) {
236 apps.value = res.data.apps
237 } else {
238 apps.value = []
239 appsError.value = res.data.message || "Failed to load apps"
240 }
241 } catch (err: unknown) {
242 apps.value = []
243 appsError.value = getApiErrorMessage(err as never) || "Failed to load apps"
244 } finally {
245 loadingApps.value = false
246 }
247 }
248
249 async function onIntegrationChange(integrationId: number | null) {
250 apps.value = []
251 form.shuffle_app_id = null
252 form.shuffle_app_name = null
253 if (integrationId) {
254 await loadApps(integrationId)
255 }
256 }
257
258 function onAppChange(appId: string | null) {
259 // Cache the app's display name alongside the UUID so the UI list can
260 // render "Slack" instead of a UUID without re-fetching the catalog
261 // every render.
262 const app = apps.value.find(a => a.id === appId)
263 form.shuffle_app_name = app?.name ?? null
264 }
265
266 const rules: FormRules = {
267 name: { required: true, message: "Name is required", trigger: ["input", "blur"] },
268 trigger: { required: true, message: "Pick a trigger", trigger: ["change", "blur"] },
269 min_severity: {
270 validator: (_rule, value: NotificationSeverity | null) => {
271 if (!value) return createFieldError("min_severity", "Pick a severity threshold")
272 clearFieldError("min_severity")
273 return true
274 },
275 trigger: ["change", "blur"]
276 },
277 destination: {
278 required: true,
279 validator: (_rule, value: string) => {
280 if (!value || !value.trim()) {
281 createFieldError("destination", "Destination hint is required")
282 }
283 clearFieldError("destination")
284 return true
285 },
286 trigger: ["input", "blur"]
287 },
288 shuffle_integration_id: {
289 required: true,
290 validator: (_rule, value: number | null) => {
291 if (form.channel === "shuffle" && !value) {
292 return createFieldError("shuffle_integration_id", "Pick a Shuffle integration")
293 }
294 clearFieldError("shuffle_integration_id")
295 return true
296 },
297 trigger: ["change", "blur"]
298 },
299 shuffle_app_id: {
300 required: true,
301 validator: (_rule, value: string | null) => {
302 if (!value) return createFieldError("shuffle_app_id", "Pick a Shuffle app")
303 clearFieldError("shuffle_app_id")
304 return true
305 },
306 trigger: ["change", "blur"]
307 }
308 }
309
310 async function submit() {
311 try {
312 await formRef.value?.validate()
313 } catch {
314 return
315 }
316
317 submitting.value = true
318 try {
319 const payload: NotificationRoutePayload = {
320 ...form,
321 format_template: form.format_template?.trim() || null
322 }
323
324 const res = props.editingRoute
325 ? await Api.notifications.updateRoute(props.customerCode, props.editingRoute.id, payload)
326 : await Api.notifications.createRoute(props.customerCode, payload)
327
328 if (res.data.success) {
329 message.success(editing.value ? "Route updated" : "Route created")
330 emit("submitted")
331 } else {
332 message.warning(res.data.message || "Failed to save route")
333 }
334 } catch (err: unknown) {
335 message.error(getApiErrorMessage(err as never) || "Failed to save route")
336 } finally {
337 submitting.value = false
338 }
339 }
340
341 onBeforeMount(async () => {
342 await loadIntegrations()
343 // If editing a Shuffle route, prefetch the apps for its integration
344 // so the picker is populated when the form first renders.
345 if (props.editingRoute?.shuffle_integration_id) {
346 await loadApps(props.editingRoute.shuffle_integration_id)
347 }
348 })
349 </script>