mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
feat(backend+web): generic JSON webhook provider + custom headers
Adds WebhookProviderType.GenericJson (20) so a tenant can POST a stable, channel-agnostic JSON payload to any HTTPS endpoint - ticketing systems, automation, or an SMTP gateway like Postal - instead of only Teams/Slack. Backend: - GenericJsonRenderer emits schemaVersion 1.0 with eventType, severity, title/summary/themeColor, primaryUrl (first openUrl action), facts[], sections[], actions[]; registered in WebhookNotificationService. - NotificationAlert.EventType set on all 8 builders (enrollment_succeeded/ _failed/_started, preprovisioning_*, hardware_rejected, sla_breach/_resolved, consecutive_failures, test) for consumer-side routing. - Per-tenant WebhookCustomHeadersJson + GetGenericWebhookHeaders() (generic-only, restricted-header denylist, fail-soft parse) for API-key auth; dispatcher gained optional customHeaders applied via HttpRequestMessage; threaded into all 12 tenant call sites (Ops/admin-config sites unchanged). - TableConfigRepository persists the field; UpdateTenantConfigurationFunction validates it (object, string values, RFC-7230 token names, CRLF-injection guard, 8 KB / 25-header caps). Web: - Generic provider option + key->value WebhookHeaderEditor (rendered only for provider 20); leaving the generic provider clears local headers so a later switch back cannot revive stale auth headers; context/types wiring sends headers only when provider===20; report shows Generic JSON provider name. ConfigVersion unchanged (backend-only, no agent payload). No new Constants.EventTypes, so no MCP catalog sync. Still fire-and-forget (no SMTP client / retry by design). New GenericWebhookTests cover renderer, header parsing/denylist, and save-time validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the generic webhook provider: payload rendering, custom-header parsing/denylist,
|
||||
/// and the save-time header validation.
|
||||
/// </summary>
|
||||
public class GenericWebhookTests
|
||||
{
|
||||
// ── GenericJsonRenderer ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GenericJsonRenderer_RendersStableSchema()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = "enrollment_succeeded",
|
||||
Title = "✅ Enrollment Succeeded",
|
||||
Summary = "Enrollment Succeeded: DESK-001",
|
||||
Severity = NotificationSeverity.Success,
|
||||
ThemeColor = "00B050",
|
||||
Facts = { new NotificationFact { Name = "Device", Value = "DESK-001" } },
|
||||
Actions = { new NotificationAction { Type = "openUrl", Title = "Open session", Url = "https://portal.example.com/sessions/abc" } },
|
||||
};
|
||||
|
||||
var json = new GenericJsonRenderer().RenderToJson(alert);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal("1.0", root.GetProperty("schemaVersion").GetString());
|
||||
Assert.Equal("enrollment_succeeded", root.GetProperty("eventType").GetString());
|
||||
Assert.Equal("Success", root.GetProperty("severity").GetString());
|
||||
Assert.Equal("Enrollment Succeeded: DESK-001", root.GetProperty("summary").GetString());
|
||||
Assert.Equal("https://portal.example.com/sessions/abc", root.GetProperty("primaryUrl").GetString());
|
||||
|
||||
var facts = root.GetProperty("facts");
|
||||
Assert.Equal(1, facts.GetArrayLength());
|
||||
Assert.Equal("Device", facts[0].GetProperty("name").GetString());
|
||||
Assert.Equal("DESK-001", facts[0].GetProperty("value").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericJsonRenderer_OmitsEventTypeAndSessionUrl_WhenAbsent()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
Title = "Test",
|
||||
Summary = "Test",
|
||||
Severity = NotificationSeverity.Info,
|
||||
};
|
||||
|
||||
var json = new GenericJsonRenderer().RenderToJson(alert);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.False(root.TryGetProperty("eventType", out _));
|
||||
Assert.False(root.TryGetProperty("primaryUrl", out _));
|
||||
Assert.Equal(WebhookProviderType.GenericJson, new GenericJsonRenderer().ProviderType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "enrollment_succeeded")]
|
||||
[InlineData(false, "enrollment_failed")]
|
||||
public void BuildEnrollmentAlert_SetsEventType(bool success, string expected)
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentAlert(
|
||||
"DESK", "SN", "Contoso", "Model", success, failureReason: "x", duration: null);
|
||||
|
||||
Assert.Equal(expected, alert.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHardwareRejectedAlert_SetsEventType()
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert("Contoso", "Model", "SN");
|
||||
Assert.Equal("hardware_rejected", alert.EventType);
|
||||
}
|
||||
|
||||
// ── TenantConfiguration.GetGenericWebhookHeaders ──────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_ReturnsEmpty_ForNonGenericProvider()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
WebhookUrl = "https://hooks.slack.com/x",
|
||||
WebhookProviderType = (int)WebhookProviderType.Slack,
|
||||
WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer x\"}",
|
||||
};
|
||||
|
||||
Assert.Empty(config.GetGenericWebhookHeaders());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_ParsesHeaders_ForGenericProvider()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
WebhookUrl = "https://tickets.example.com/in",
|
||||
WebhookProviderType = (int)WebhookProviderType.GenericJson,
|
||||
WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer abc\",\"X-Api-Key\":\"k1\"}",
|
||||
};
|
||||
|
||||
var headers = config.GetGenericWebhookHeaders();
|
||||
Assert.Equal("Bearer abc", headers["Authorization"]);
|
||||
Assert.Equal("k1", headers["X-Api-Key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_DropsRestrictedHeaders()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
WebhookUrl = "https://tickets.example.com/in",
|
||||
WebhookProviderType = (int)WebhookProviderType.GenericJson,
|
||||
WebhookCustomHeadersJson = "{\"Host\":\"evil.example.com\",\"Content-Length\":\"0\",\"X-Ok\":\"v\"}",
|
||||
};
|
||||
|
||||
var headers = config.GetGenericWebhookHeaders();
|
||||
Assert.False(headers.ContainsKey("Host"));
|
||||
Assert.False(headers.ContainsKey("Content-Length"));
|
||||
Assert.True(headers.ContainsKey("X-Ok"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_ReturnsEmpty_ForMalformedJson()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
WebhookUrl = "https://tickets.example.com/in",
|
||||
WebhookProviderType = (int)WebhookProviderType.GenericJson,
|
||||
WebhookCustomHeadersJson = "{not valid",
|
||||
};
|
||||
|
||||
Assert.Empty(config.GetGenericWebhookHeaders());
|
||||
}
|
||||
|
||||
// ── UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders ────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{\"Authorization\":\"Bearer abc\"}")]
|
||||
public void ValidateWebhookCustomHeaders_Accepts_EmptyOrValid(string? json)
|
||||
{
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{not json", "not valid JSON.")]
|
||||
[InlineData("[\"a\",\"b\"]", "must be a JSON object")]
|
||||
[InlineData("{\"X-Key\":123}", "must have a string value")]
|
||||
[InlineData("{\"Bad Header\":\"v\"}", "not a valid HTTP header name")]
|
||||
[InlineData("{\"X-Key\":\"line1\\nline2\"}", "must not contain line breaks")]
|
||||
public void ValidateWebhookCustomHeaders_Rejects_Invalid(string json, string expectedFragment)
|
||||
{
|
||||
var error = UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expectedFragment, error);
|
||||
}
|
||||
}
|
||||
+2
@@ -374,6 +374,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "WebhookNotifyOnFailure", config.WebhookNotifyOnFailure },
|
||||
{ "WebhookNotifyOnHardwareRejection", config.WebhookNotifyOnHardwareRejection },
|
||||
{ "WebhookNotifyOnStart", config.WebhookNotifyOnStart },
|
||||
{ "WebhookCustomHeadersJson", config.WebhookCustomHeadersJson },
|
||||
{ "ShowScriptOutput", config.ShowScriptOutput },
|
||||
{ "ShowEnrollmentSummary", config.ShowEnrollmentSummary },
|
||||
{ "EnrollmentSummaryTimeoutSeconds", config.EnrollmentSummaryTimeoutSeconds },
|
||||
@@ -464,6 +465,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
WebhookNotifyOnFailure = entity.GetBoolean("WebhookNotifyOnFailure") ?? true,
|
||||
WebhookNotifyOnHardwareRejection = entity.GetBoolean("WebhookNotifyOnHardwareRejection") ?? false,
|
||||
WebhookNotifyOnStart = entity.GetBoolean("WebhookNotifyOnStart") ?? false,
|
||||
WebhookCustomHeadersJson = entity.GetString("WebhookCustomHeadersJson"),
|
||||
ShowScriptOutput = entity.GetBoolean("ShowScriptOutput") ?? true,
|
||||
ShowEnrollmentSummary = entity.GetBoolean("ShowEnrollmentSummary"),
|
||||
EnrollmentSummaryTimeoutSeconds = entity.GetInt32("EnrollmentSummaryTimeoutSeconds"),
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
var providerType = (WebhookProviderType)providerTypeInt;
|
||||
var testAlert = NotificationAlertBuilder.BuildTestAlert();
|
||||
var result = await _webhookNotificationService.SendNotificationWithResultAsync(webhookUrl, providerType, testAlert);
|
||||
var result = await _webhookNotificationService.SendNotificationWithResultAsync(webhookUrl, providerType, testAlert, tenantConfig.GetGenericWebhookHeaders());
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
|
||||
+78
@@ -78,6 +78,13 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid Teams Webhook URL: {teamsUrlError}" });
|
||||
return badRequest;
|
||||
}
|
||||
var headersError = ValidateWebhookCustomHeaders(config.WebhookCustomHeadersJson);
|
||||
if (headersError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid custom headers: {headersError}" });
|
||||
return badRequest;
|
||||
}
|
||||
// Only validate the customer-supplied SAS URL when the tenant has actually
|
||||
// selected the CustomerSas destination. In Hosted mode the field is unused at
|
||||
// runtime (see GetDiagnosticsUploadUrlFunction), so a stale/legacy value left
|
||||
@@ -176,5 +183,76 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private const int MaxCustomHeadersJsonLength = 8192;
|
||||
private const int MaxCustomHeaderCount = 25;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the generic-webhook custom-headers JSON. Returns an error message, or null when
|
||||
/// valid/empty. Enforces a JSON object of string values, valid HTTP token names, no CR/LF
|
||||
/// header-injection, and size caps. Restricted (framing/host/content) headers are not rejected
|
||||
/// here — they are silently ignored at dispatch by TenantConfiguration.GetGenericWebhookHeaders().
|
||||
/// </summary>
|
||||
internal static string? ValidateWebhookCustomHeaders(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
if (json.Length > MaxCustomHeadersJsonLength)
|
||||
return $"too large (max {MaxCustomHeadersJsonLength} characters).";
|
||||
|
||||
System.Text.Json.JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return "not valid JSON.";
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object)
|
||||
return "must be a JSON object of header name/value pairs.";
|
||||
|
||||
var count = 0;
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (++count > MaxCustomHeaderCount)
|
||||
return $"too many headers (max {MaxCustomHeaderCount}).";
|
||||
|
||||
if (prop.Value.ValueKind != System.Text.Json.JsonValueKind.String)
|
||||
return $"header \"{prop.Name}\" must have a string value.";
|
||||
|
||||
if (!IsValidHeaderName(prop.Name))
|
||||
return $"\"{prop.Name}\" is not a valid HTTP header name.";
|
||||
|
||||
var value = prop.Value.GetString();
|
||||
if (value != null && (value.IndexOf('\r') >= 0 || value.IndexOf('\n') >= 0))
|
||||
return $"value for \"{prop.Name}\" must not contain line breaks.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Validates an HTTP header name as an RFC 7230 token (no whitespace, controls, or separators).</summary>
|
||||
private static bool IsValidHeaderName(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return false;
|
||||
|
||||
foreach (var ch in name)
|
||||
{
|
||||
var isTokenChar =
|
||||
(ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
|
||||
"!#$%&'*+-.^_`|~".IndexOf(ch) >= 0;
|
||||
if (!isTokenChar)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -870,6 +870,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
return;
|
||||
|
||||
var providerType = (WebhookProviderType)providerTypeInt;
|
||||
var customHeaders = tenantConfig.GetGenericWebhookHeaders();
|
||||
var sessionUrl = updatedSession != null
|
||||
? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}"
|
||||
: null;
|
||||
@@ -905,7 +906,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
sessionUrl: sessionUrl);
|
||||
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
|
||||
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
@@ -928,7 +929,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
sessionUrl: sessionUrl);
|
||||
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
|
||||
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
@@ -950,7 +951,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
sessionUrl: sessionUrl);
|
||||
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
|
||||
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
if (!string.IsNullOrEmpty(webhookUrl) && providerTypeInt != 0)
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
|
||||
_ = _webhookNotification.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert);
|
||||
_ = _webhookNotification.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert, config.GetGenericWebhookHeaders());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -260,7 +260,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
isResume: isResume,
|
||||
sessionUrl: sessionUrl);
|
||||
|
||||
await _webhookNotificationService.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert);
|
||||
await _webhookNotificationService.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert, tenantConfig.GetGenericWebhookHeaders());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+4
-3
@@ -27,6 +27,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return;
|
||||
|
||||
var providerType = (WebhookProviderType)providerTypeInt;
|
||||
var customHeaders = tenantConfig.GetGenericWebhookHeaders();
|
||||
var sessionUrl = updatedSession != null
|
||||
? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}"
|
||||
: null;
|
||||
@@ -59,7 +60,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
sessionUrl: sessionUrl);
|
||||
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
|
||||
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
@@ -81,7 +82,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
sessionUrl: sessionUrl);
|
||||
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
|
||||
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
@@ -102,7 +103,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
sessionUrl: sessionUrl);
|
||||
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
|
||||
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
|
||||
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a NotificationAlert as a stable, channel-agnostic JSON payload for generic webhook
|
||||
/// consumers (ticketing systems, automation, SMTP gateways such as Postal).
|
||||
///
|
||||
/// Payload contract (schemaVersion "1.0"):
|
||||
/// {
|
||||
/// "schemaVersion": "1.0",
|
||||
/// "eventType": "enrollment_succeeded", // omitted when not set
|
||||
/// "severity": "Success", // Info | Success | Warning | Error
|
||||
/// "title": "...", "summary": "...", "themeColor": "00B050",
|
||||
/// "primaryUrl": "https://.../sessions/{id}", // first openUrl action (session/dashboard link), omitted when none
|
||||
/// "facts": [ { "name": "Device", "value": "..." } ],
|
||||
/// "sections": [ { "title": "...", "text": "..." } ],
|
||||
/// "actions": [ { "type": "openUrl", "title": "...", "url": "..." } ]
|
||||
/// }
|
||||
/// </summary>
|
||||
public class GenericJsonRenderer : INotificationRenderer
|
||||
{
|
||||
/// <summary>Bump only on a breaking change to the payload contract above.</summary>
|
||||
private const string SchemaVersion = "1.0";
|
||||
|
||||
public WebhookProviderType ProviderType => WebhookProviderType.GenericJson;
|
||||
|
||||
public string RenderToJson(NotificationAlert alert)
|
||||
{
|
||||
// First openUrl action: a session link for enrollment alerts, a dashboard/portal link for
|
||||
// SLA/test alerts. Named generically (primaryUrl) since it is not always a session URL;
|
||||
// consumers can still inspect actions[] (each carries its title) to disambiguate.
|
||||
var primaryUrl = alert.Actions
|
||||
.FirstOrDefault(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url))?.Url;
|
||||
|
||||
var payload = new
|
||||
{
|
||||
schemaVersion = SchemaVersion,
|
||||
eventType = string.IsNullOrEmpty(alert.EventType) ? null : alert.EventType,
|
||||
severity = alert.Severity.ToString(),
|
||||
title = alert.Title,
|
||||
summary = alert.Summary,
|
||||
themeColor = string.IsNullOrEmpty(alert.ThemeColor) ? null : alert.ThemeColor,
|
||||
primaryUrl,
|
||||
facts = alert.Facts.Select(f => new { name = f.Name, value = f.Value }).ToArray(),
|
||||
sections = alert.Sections.Select(s => new { title = s.Title, text = s.Text }).ToArray(),
|
||||
actions = alert.Actions.Select(a => new { type = a.Type, title = a.Title, url = a.Url }).ToArray(),
|
||||
};
|
||||
|
||||
return JsonConvert.SerializeObject(payload, new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -47,6 +47,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = success ? "enrollment_succeeded" : "enrollment_failed",
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
Severity = severity,
|
||||
@@ -84,6 +85,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = isResume ? "preprovisioning_resumed" : "enrollment_started",
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
Severity = NotificationSeverity.Info,
|
||||
@@ -127,6 +129,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = success ? "preprovisioning_completed" : "preprovisioning_failed",
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
Severity = severity,
|
||||
@@ -150,6 +153,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
return new NotificationAlert
|
||||
{
|
||||
EventType = "test",
|
||||
Title = "\ud83d\udd14 Test Notification",
|
||||
Summary = "This is a test notification from Autopilot Monitor.",
|
||||
Severity = NotificationSeverity.Info,
|
||||
@@ -175,6 +179,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
return new NotificationAlert
|
||||
{
|
||||
EventType = "hardware_rejected",
|
||||
Title = "\u26a0\ufe0f Hardware Not Whitelisted",
|
||||
Summary = $"Device rejected: {hardwareText} is not in your hardware whitelist",
|
||||
Severity = NotificationSeverity.Warning,
|
||||
@@ -298,6 +303,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = "sla_breach",
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
Severity = NotificationSeverity.Warning,
|
||||
@@ -346,6 +352,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = "sla_resolved",
|
||||
Title = $"✅ SLA Breach Resolved: {label}",
|
||||
Summary = $"SLA breach resolved for tenant {tenantId}: {label} is back within target.",
|
||||
Severity = NotificationSeverity.Success,
|
||||
@@ -375,6 +382,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = "consecutive_failures",
|
||||
Title = $"\ud83d\udea8 {consecutiveFailures} Consecutive Enrollment Failures",
|
||||
Summary = $"Alert: {consecutiveFailures} enrollments failed in a row for tenant {tenantId}",
|
||||
Severity = NotificationSeverity.Error,
|
||||
|
||||
+29
-6
@@ -28,13 +28,17 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
[WebhookProviderType.TeamsLegacyConnector] = new LegacyTeamsConnectorRenderer(),
|
||||
[WebhookProviderType.TeamsWorkflowWebhook] = new TeamsWorkflowAdaptiveCardRenderer(),
|
||||
[WebhookProviderType.Slack] = new SlackRenderer(),
|
||||
[WebhookProviderType.GenericJson] = new GenericJsonRenderer(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a notification (fire-and-forget, non-fatal). Exceptions are logged as warnings.
|
||||
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request,
|
||||
/// e.g. an API-key/Authorization header for a ticketing system or SMTP gateway.
|
||||
/// </summary>
|
||||
public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert)
|
||||
public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
|
||||
IReadOnlyDictionary<string, string>? customHeaders = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None)
|
||||
return;
|
||||
@@ -50,8 +54,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
await SsrfGuard.ValidateDestinationAsync(webhookUrl);
|
||||
|
||||
var json = renderer.RenderToJson(alert);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var response = await _http.PostAsync(webhookUrl, content);
|
||||
var response = await PostAsync(webhookUrl, json, customHeaders);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
_logger.LogInformation("Webhook notification sent: {Summary} (provider={ProviderType})", alert.Summary, providerType);
|
||||
@@ -69,8 +72,10 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
/// <summary>
|
||||
/// Sends a notification and returns the result (for test endpoint). Not fire-and-forget.
|
||||
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request.
|
||||
/// </summary>
|
||||
public async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert)
|
||||
public async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
|
||||
IReadOnlyDictionary<string, string>? customHeaders = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(webhookUrl))
|
||||
return new WebhookTestResult { Success = false, Message = "Webhook URL is not configured." };
|
||||
@@ -93,8 +98,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
try
|
||||
{
|
||||
var json = renderer.RenderToJson(alert);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var response = await _http.PostAsync(webhookUrl, content);
|
||||
var response = await PostAsync(webhookUrl, json, customHeaders);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
@@ -116,6 +120,25 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POSTs the rendered JSON, attaching any custom headers as request headers. Restricted
|
||||
/// (framing/host/content) headers are already filtered upstream by GetGenericWebhookHeaders().
|
||||
/// </summary>
|
||||
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? customHeaders)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
if (customHeaders != null)
|
||||
{
|
||||
foreach (var header in customHeaders)
|
||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
return _http.SendAsync(request);
|
||||
}
|
||||
}
|
||||
|
||||
public class WebhookTestResult
|
||||
|
||||
@@ -551,7 +551,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig();
|
||||
if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0)
|
||||
{
|
||||
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert);
|
||||
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders());
|
||||
}
|
||||
|
||||
await _tenantNotificationService.CreateNotificationAsync(
|
||||
@@ -594,7 +594,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig();
|
||||
if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0)
|
||||
{
|
||||
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert);
|
||||
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders());
|
||||
}
|
||||
|
||||
await _tenantNotificationService.CreateNotificationAsync(
|
||||
@@ -682,7 +682,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig();
|
||||
if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0)
|
||||
{
|
||||
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert);
|
||||
await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders());
|
||||
}
|
||||
|
||||
await _tenantNotificationService.CreateNotificationAsync(
|
||||
|
||||
@@ -548,6 +548,15 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool WebhookNotifyOnStart { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Custom HTTP request headers (JSON object: { "Header-Name": "value", ... }) sent with every
|
||||
/// generic-webhook POST. Used for API-key authentication against ticketing systems / SMTP gateways.
|
||||
/// Only applied when the effective provider is <see cref="Notifications.WebhookProviderType.GenericJson"/>.
|
||||
/// Restricted headers (Host, Content-Length, Content-Type, etc.) are ignored — see
|
||||
/// <see cref="GetGenericWebhookHeaders"/>.
|
||||
/// </summary>
|
||||
public string WebhookCustomHeadersJson { get; set; } = default!;
|
||||
|
||||
// ===== SLA TARGETS =====
|
||||
|
||||
/// <summary>
|
||||
@@ -640,6 +649,64 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool GetEffectiveNotifyOnStart()
|
||||
=> !string.IsNullOrEmpty(WebhookUrl) ? WebhookNotifyOnStart : TeamsNotifyOnStart;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP headers that must never be set via the custom-header mechanism: framing/host/content
|
||||
/// headers are owned by the HTTP client and overriding them breaks the request or enables
|
||||
/// request-smuggling. Compared case-insensitively.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> RestrictedWebhookHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Host", "Content-Length", "Content-Type", "Transfer-Encoding", "Connection",
|
||||
"Keep-Alive", "Upgrade", "TE", "Trailer", "Expect", "Proxy-Connection",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses <see cref="WebhookCustomHeadersJson"/> into header name/value pairs for the generic
|
||||
/// webhook dispatcher. Returns an empty dictionary unless the effective provider is
|
||||
/// <see cref="Notifications.WebhookProviderType.GenericJson"/>, the JSON is a parseable object,
|
||||
/// and after dropping restricted headers and blank names/values. Never throws.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> GetGenericWebhookHeaders()
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var (_, providerType) = GetEffectiveWebhookConfig();
|
||||
if (providerType != (int)Notifications.WebhookProviderType.GenericJson)
|
||||
return result;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(WebhookCustomHeadersJson))
|
||||
return result;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(WebhookCustomHeadersJson);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return result;
|
||||
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (prop.Value.ValueKind != JsonValueKind.String)
|
||||
continue;
|
||||
|
||||
var name = prop.Name.Trim();
|
||||
var value = prop.Value.GetString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrEmpty(value))
|
||||
continue;
|
||||
if (RestrictedWebhookHeaders.Contains(name))
|
||||
continue;
|
||||
|
||||
result[name] = value!;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Malformed JSON → no custom headers (fail-soft; dispatch still proceeds).
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the tenant is currently disabled
|
||||
/// Takes into account DisabledUntil if set
|
||||
|
||||
@@ -9,6 +9,13 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
/// </summary>
|
||||
public class NotificationAlert
|
||||
{
|
||||
/// <summary>
|
||||
/// Machine-readable event type for routing/filtering by generic webhook consumers
|
||||
/// (e.g. "enrollment_succeeded", "enrollment_failed", "hardware_rejected", "sla_breach").
|
||||
/// Only serialized by the generic JSON renderer; ignored by Teams/Slack renderers.
|
||||
/// </summary>
|
||||
public string? EventType { get; set; }
|
||||
|
||||
/// <summary>Main title, e.g. "Enrollment Succeeded".</summary>
|
||||
public string Title { get; set; } = default!;
|
||||
|
||||
|
||||
@@ -16,5 +16,12 @@ namespace AutopilotMonitor.Shared.Models.Notifications
|
||||
|
||||
/// <summary>Slack Incoming Webhook (Block Kit format).</summary>
|
||||
Slack = 10,
|
||||
|
||||
/// <summary>
|
||||
/// Generic JSON webhook. Posts a stable, channel-agnostic JSON payload (schemaVersion + eventType)
|
||||
/// to any HTTP endpoint — for ticketing systems, automation, or SMTP gateways (e.g. Postal).
|
||||
/// Supports per-tenant custom request headers for API-key authentication.
|
||||
/// </summary>
|
||||
GenericJson = 20,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ const WEBHOOK_PROVIDERS: Record<number, string> = {
|
||||
1: 'Teams (Legacy Connector)',
|
||||
2: 'Teams (Workflow Webhook)',
|
||||
10: 'Slack',
|
||||
20: 'Generic JSON',
|
||||
};
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
|
||||
@@ -153,6 +153,8 @@ interface TenantConfigContextValue {
|
||||
setWebhookNotifyOnFailure: (v: boolean) => void;
|
||||
webhookNotifyOnStart: boolean;
|
||||
setWebhookNotifyOnStart: (v: boolean) => void;
|
||||
webhookCustomHeaders: string;
|
||||
setWebhookCustomHeaders: (v: string) => void;
|
||||
testingWebhook: boolean;
|
||||
testWebhookResult: { success: boolean; message: string } | null;
|
||||
handleTestWebhook: () => Promise<void>;
|
||||
@@ -334,6 +336,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
const [webhookNotifyOnSuccess, setWebhookNotifyOnSuccess] = useState(true);
|
||||
const [webhookNotifyOnFailure, setWebhookNotifyOnFailure] = useState(true);
|
||||
const [webhookNotifyOnStart, setWebhookNotifyOnStart] = useState(false);
|
||||
const [webhookCustomHeaders, setWebhookCustomHeaders] = useState("");
|
||||
const [testingWebhook, setTestingWebhook] = useState(false);
|
||||
const [testWebhookResult, setTestWebhookResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
@@ -439,18 +442,21 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setWebhookNotifyOnSuccess(data.webhookNotifyOnSuccess ?? true);
|
||||
setWebhookNotifyOnFailure(data.webhookNotifyOnFailure ?? true);
|
||||
setWebhookNotifyOnStart(data.webhookNotifyOnStart ?? false);
|
||||
setWebhookCustomHeaders(data.webhookCustomHeadersJson ?? "");
|
||||
} else if (data.teamsWebhookUrl) {
|
||||
setWebhookProviderType(1); // TeamsLegacyConnector
|
||||
setWebhookUrl(data.teamsWebhookUrl);
|
||||
setWebhookNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true);
|
||||
setWebhookNotifyOnFailure(data.teamsNotifyOnFailure ?? true);
|
||||
setWebhookNotifyOnStart(data.teamsNotifyOnStart ?? false);
|
||||
setWebhookCustomHeaders("");
|
||||
} else {
|
||||
setWebhookProviderType(0);
|
||||
setWebhookUrl("");
|
||||
setWebhookNotifyOnSuccess(true);
|
||||
setWebhookNotifyOnFailure(true);
|
||||
setWebhookNotifyOnStart(false);
|
||||
setWebhookCustomHeaders("");
|
||||
}
|
||||
// SLA Targets
|
||||
setSlaTargetSuccessRate(data.slaTargetSuccessRate ?? null);
|
||||
@@ -650,6 +656,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
webhookNotifyOnSuccess,
|
||||
webhookNotifyOnFailure,
|
||||
webhookNotifyOnStart,
|
||||
// Custom headers only apply to the generic provider; clear them otherwise so a
|
||||
// provider switch does not leave stale auth headers persisted.
|
||||
webhookCustomHeadersJson: webhookProviderType === 20 ? (webhookCustomHeaders || undefined) : undefined,
|
||||
// Legacy compat: mirror to old fields during transition
|
||||
teamsWebhookUrl: webhookProviderType === 1 ? (webhookUrl || undefined) : undefined,
|
||||
teamsNotifyOnSuccess: webhookNotifyOnSuccess,
|
||||
@@ -721,6 +730,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, logLevel, showScriptOutput, showEnrollmentSummary,
|
||||
enrollmentSummaryTimeoutSeconds, enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
|
||||
webhookProviderType, webhookUrl, webhookNotifyOnSuccess, webhookNotifyOnFailure, webhookNotifyOnStart,
|
||||
webhookCustomHeaders,
|
||||
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
|
||||
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
|
||||
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
|
||||
@@ -1064,18 +1074,21 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setWebhookNotifyOnSuccess(config.webhookNotifyOnSuccess ?? true);
|
||||
setWebhookNotifyOnFailure(config.webhookNotifyOnFailure ?? true);
|
||||
setWebhookNotifyOnStart(config.webhookNotifyOnStart ?? false);
|
||||
setWebhookCustomHeaders(config.webhookCustomHeadersJson ?? "");
|
||||
} else if (config.teamsWebhookUrl) {
|
||||
setWebhookProviderType(1);
|
||||
setWebhookUrl(config.teamsWebhookUrl);
|
||||
setWebhookNotifyOnSuccess(config.teamsNotifyOnSuccess ?? true);
|
||||
setWebhookNotifyOnFailure(config.teamsNotifyOnFailure ?? true);
|
||||
setWebhookNotifyOnStart(config.teamsNotifyOnStart ?? false);
|
||||
setWebhookCustomHeaders("");
|
||||
} else {
|
||||
setWebhookProviderType(0);
|
||||
setWebhookUrl("");
|
||||
setWebhookNotifyOnSuccess(true);
|
||||
setWebhookNotifyOnFailure(true);
|
||||
setWebhookNotifyOnStart(false);
|
||||
setWebhookCustomHeaders("");
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
@@ -1456,6 +1469,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
webhookNotifyOnSuccess, setWebhookNotifyOnSuccess,
|
||||
webhookNotifyOnFailure, setWebhookNotifyOnFailure,
|
||||
webhookNotifyOnStart, setWebhookNotifyOnStart,
|
||||
webhookCustomHeaders, setWebhookCustomHeaders,
|
||||
testingWebhook, testWebhookResult,
|
||||
handleTestWebhook, handleSaveNotifications, handleResetNotifications,
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import SaveResetBar from "./SaveResetBar";
|
||||
|
||||
interface NotificationsSectionProps {
|
||||
@@ -13,6 +14,8 @@ interface NotificationsSectionProps {
|
||||
setWebhookNotifyOnFailure: (v: boolean) => void;
|
||||
webhookNotifyOnStart: boolean;
|
||||
setWebhookNotifyOnStart: (v: boolean) => void;
|
||||
webhookCustomHeaders: string;
|
||||
setWebhookCustomHeaders: (v: string) => void;
|
||||
onTestWebhook: () => Promise<void>;
|
||||
testingWebhook: boolean;
|
||||
testWebhookResult: { success: boolean; message: string } | null;
|
||||
@@ -21,13 +24,113 @@ interface NotificationsSectionProps {
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
const GENERIC_PROVIDER = 20;
|
||||
|
||||
const PROVIDERS = [
|
||||
{ value: 0, label: "None (disabled)", placeholder: "" },
|
||||
{ value: 1, label: "Microsoft Teams (Legacy Connector)", placeholder: "https://your-org.webhook.office.com/webhookb2/...", badge: "Deprecated", badgeColor: "bg-amber-100 text-amber-800" },
|
||||
{ value: 2, label: "Microsoft Teams (Workflow Webhook)", placeholder: "https://prod-xx.westeurope.logic.azure.com:443/workflows/...", badge: "Recommended", badgeColor: "bg-green-100 text-green-800" },
|
||||
{ value: 10, label: "Slack", placeholder: "https://hooks.slack.com/services/T.../B.../..." },
|
||||
{ value: GENERIC_PROVIDER, label: "Generic JSON (ticketing / automation)", placeholder: "https://your-system.example.com/api/webhooks/autopilot" },
|
||||
];
|
||||
|
||||
type HeaderRow = { key: string; value: string };
|
||||
|
||||
function parseHeaderRows(json: string): HeaderRow[] {
|
||||
if (!json || !json.trim()) return [];
|
||||
try {
|
||||
const obj = JSON.parse(json);
|
||||
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
||||
return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) }));
|
||||
}
|
||||
} catch {
|
||||
// Malformed JSON (e.g. legacy/hand-edited value) — start from an empty editor.
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function serializeHeaderRows(rows: HeaderRow[]): string {
|
||||
const obj: Record<string, string> = {};
|
||||
for (const { key, value } of rows) {
|
||||
const k = key.trim();
|
||||
if (k) obj[k] = value;
|
||||
}
|
||||
return Object.keys(obj).length > 0 ? JSON.stringify(obj) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Key→value editor for generic-webhook custom headers. Owns row state locally and serializes
|
||||
* to the JSON string the backend persists; re-syncs only on external changes (config load/reset).
|
||||
*/
|
||||
function WebhookHeaderEditor({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [rows, setRows] = useState<HeaderRow[]>(() => parseHeaderRows(value));
|
||||
const lastSerialized = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== lastSerialized.current) {
|
||||
setRows(parseHeaderRows(value));
|
||||
lastSerialized.current = value;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const commit = (next: HeaderRow[]) => {
|
||||
setRows(next);
|
||||
const json = serializeHeaderRows(next);
|
||||
lastSerialized.current = json;
|
||||
onChange(json);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-gray-700 font-medium">Custom Headers</span>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Optional HTTP headers sent with every request — e.g. <code className="font-mono">Authorization</code> with an API key
|
||||
for your ticketing system or SMTP gateway. Framing headers (Host, Content-Type, …) are ignored.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={row.key}
|
||||
onChange={(e) => commit(rows.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)))}
|
||||
placeholder="Header name"
|
||||
className="block w-1/3 px-3 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={row.value}
|
||||
onChange={(e) => commit(rows.map((r, j) => (j === i ? { ...r, value: e.target.value } : r)))}
|
||||
placeholder="Value"
|
||||
className="block flex-1 px-3 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commit(rows.filter((_, j) => j !== i))}
|
||||
className="flex-shrink-0 p-2 text-gray-400 hover:text-red-600 transition-colors"
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commit([...rows, { key: "", value: "" }])}
|
||||
className="mt-2 inline-flex items-center text-sm font-medium text-sky-600 hover:text-sky-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add header
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationsSection({
|
||||
webhookProviderType,
|
||||
setWebhookProviderType,
|
||||
@@ -39,6 +142,8 @@ export default function NotificationsSection({
|
||||
setWebhookNotifyOnFailure,
|
||||
webhookNotifyOnStart,
|
||||
setWebhookNotifyOnStart,
|
||||
webhookCustomHeaders,
|
||||
setWebhookCustomHeaders,
|
||||
onTestWebhook,
|
||||
testingWebhook,
|
||||
testWebhookResult,
|
||||
@@ -74,7 +179,13 @@ export default function NotificationsSection({
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={webhookProviderType}
|
||||
onChange={(e) => setWebhookProviderType(Number(e.target.value))}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
setWebhookProviderType(next);
|
||||
// Custom headers are generic-only; clear them when leaving the generic provider so
|
||||
// a later switch back can't revive stale auth headers and persist them to a new endpoint.
|
||||
if (next !== GENERIC_PROVIDER) setWebhookCustomHeaders("");
|
||||
}}
|
||||
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors"
|
||||
>
|
||||
{PROVIDERS.map((p) => (
|
||||
@@ -111,6 +222,8 @@ export default function NotificationsSection({
|
||||
? "Create a Workflow in Teams (Channel → Manage channel → Workflows → \"Post to a channel when a webhook request is received\") and paste the URL here."
|
||||
: webhookProviderType === 10
|
||||
? "Create an Incoming Webhook in Slack (Apps → Incoming Webhooks → Add to channel) and paste the URL here."
|
||||
: webhookProviderType === GENERIC_PROVIDER
|
||||
? "Any HTTPS endpoint that accepts a JSON POST. Receives a stable payload (schemaVersion + eventType, e.g. \"enrollment_succeeded\") for ticketing, automation, or an SMTP gateway like Postal."
|
||||
: "Create an Incoming Webhook in your Teams channel (Channel → Connectors → Incoming Webhook) and paste the URL here."}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -131,6 +244,11 @@ export default function NotificationsSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Headers (generic provider only) */}
|
||||
{webhookProviderType === GENERIC_PROVIDER && (
|
||||
<WebhookHeaderEditor value={webhookCustomHeaders} onChange={setWebhookCustomHeaders} />
|
||||
)}
|
||||
|
||||
{/* Notify on Start */}
|
||||
{webhookProviderType !== 0 && (
|
||||
<div className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${isActive ? 'border-gray-200 hover:border-sky-200' : 'border-gray-100 opacity-50'}`}>
|
||||
|
||||
@@ -11,6 +11,7 @@ export function SectionNotifications() {
|
||||
webhookNotifyOnSuccess, setWebhookNotifyOnSuccess,
|
||||
webhookNotifyOnFailure, setWebhookNotifyOnFailure,
|
||||
webhookNotifyOnStart, setWebhookNotifyOnStart,
|
||||
webhookCustomHeaders, setWebhookCustomHeaders,
|
||||
handleTestWebhook, testingWebhook, testWebhookResult,
|
||||
handleSaveNotifications, handleResetNotifications,
|
||||
savingSection,
|
||||
@@ -30,6 +31,8 @@ export function SectionNotifications() {
|
||||
setWebhookNotifyOnFailure={setWebhookNotifyOnFailure}
|
||||
webhookNotifyOnStart={webhookNotifyOnStart}
|
||||
setWebhookNotifyOnStart={setWebhookNotifyOnStart}
|
||||
webhookCustomHeaders={webhookCustomHeaders}
|
||||
setWebhookCustomHeaders={setWebhookCustomHeaders}
|
||||
onTestWebhook={handleTestWebhook}
|
||||
testingWebhook={testingWebhook}
|
||||
testWebhookResult={testWebhookResult}
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface TenantConfiguration {
|
||||
webhookNotifyOnFailure?: boolean;
|
||||
webhookNotifyOnHardwareRejection?: boolean;
|
||||
webhookNotifyOnStart?: boolean;
|
||||
webhookCustomHeadersJson?: string;
|
||||
// SLA targets
|
||||
slaTargetSuccessRate?: number;
|
||||
slaTargetMaxDurationMinutes?: number;
|
||||
|
||||
Reference in New Issue
Block a user