| 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 <destination>: …</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> |