feat: every alert links to its subject; ops alerts open their own event (D-360)

A push carries only category, event and severity for an ops alert, so the
portal link is the way to the details, and ops alerts had none: the event
id was minted inside the save and never reached the dispatch.

Backend: SaveOpsEventAsync sets OpsEventEntry.Id (OpsEventId,
{Category}_{RowKey}); WriteAsync hands it to the dispatch, and BuildAlert
adds "Open in the portal" -> /admin/ops/?eventId=<id> on the plain and the
payload path, so every ops channel (push, Telegram, Teams, Slack, Discord,
e-mail, JSON) gets it. GET global/ops-events?id= is a point read (400 for a
malformed id, an empty list for a deleted one). hardware_rejected links to
the hardware whitelist (one path constant, also for the bell); the session
watch link names the session's tenant; TenantSignup's portalPath keeps its
tenant query. Only a session link makes a push session-bound: tag and Topic
no longer key on any "id=" in the link.

Web: /admin/ops?eventId= opens that event's detail from the point read
(matched by id), and the detail links to the tenant and to the event's
related page (same-origin paths only) besides the session, which now
carries the tenant. CopyButton and the cursor pager footer are shared
components instead of four and three copies; the duplicate baseline shrinks
by five entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-14 17:19:22 +02:00
parent bf37fe46c1
commit caeb7a3e58
23 changed files with 578 additions and 182 deletions
@@ -0,0 +1,192 @@
using System.Net;
using System.Security.Claims;
using System.Text.Json;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Every ops alert links to exactly the event that raised it: the save assigns the id, the
/// dispatch carries it into an "Open in the portal" action on every path, and the ops-events
/// endpoint resolves it with one point read. Before this an ops push had no link at all.
/// </summary>
public class OpsEventDeepLinkTests
{
private const string RowKey = "2516103215872799164";
private static readonly string SecurityId = OpsEventCategory.Security + "_" + RowKey;
// ── The id shape ──────────────────────────────────────────────────────
[Fact]
public void OpsEventId_round_trips_every_category()
{
foreach (var category in OpsEventCategory.All)
{
Assert.True(OpsEventId.TryParse(OpsEventId.Format(category, RowKey), out var c, out var rk));
Assert.Equal(category, c);
Assert.Equal(RowKey, rk);
}
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Security")] // no RowKey
[InlineData("_2516103215872799164")] // no category
[InlineData("Unknown_2516103215872799164")] // not a category
[InlineData("security_2516103215872799164")] // PartitionKeys are case-sensitive
[InlineData("Security_251610321587279916")] // 18 digits
[InlineData("Security_25161032158727991640")] // 20 digits
[InlineData("Security_25161032158727991x4")] // not a digit
[InlineData("Security_' or 'a' eq 'a")] // never reaches a query
public void OpsEventId_rejects_anything_that_is_not_an_id(string? id)
=> Assert.False(OpsEventId.TryParse(id, out _, out _));
[Fact]
public void Read_rows_carry_the_same_id_the_link_uses()
{
var entry = TableOpsEventRepository.MapToEntry(new TableEntity(OpsEventCategory.Security, RowKey));
Assert.Equal(SecurityId, entry.Id);
}
[Fact]
public void The_portal_link_carries_the_id_only()
=> Assert.Equal(Constants.PortalBaseUrl + "/admin/ops/?eventId=" + SecurityId, Constants.PortalOpsEventUrl(SecurityId));
// ── GET global/ops-events?id= ─────────────────────────────────────────
[Fact]
public async Task Id_query_is_a_point_read_that_ignores_the_list_filters()
{
var repo = new Mock<IOpsEventRepository>();
repo.Setup(r => r.GetOpsEventAsync(SecurityId))
.ReturnsAsync(new OpsEventEntry { Id = SecurityId, Category = OpsEventCategory.Security, EventType = "DeviceBlocked" });
var (status, body) = await RunAsync(repo, $"?id={SecurityId}&category=Tenant&pageSize=5");
Assert.Equal(HttpStatusCode.OK, status);
Assert.Equal(1, body.GetProperty("count").GetInt32());
Assert.Equal(SecurityId, body.GetProperty("events")[0].GetProperty("id").GetString());
repo.Verify(r => r.GetOpsEventsPageAsync(It.IsAny<string?>(), It.IsAny<DateTime?>(), It.IsAny<DateTime?>(),
It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<OpsEventQueryFilters?>()), Times.Never);
}
[Fact]
public async Task Id_query_for_a_gone_event_is_an_empty_list_not_an_error()
{
var repo = new Mock<IOpsEventRepository>();
var (status, body) = await RunAsync(repo, $"?id={SecurityId}");
Assert.Equal(HttpStatusCode.OK, status);
Assert.Equal(0, body.GetProperty("count").GetInt32());
}
[Fact]
public async Task Malformed_id_is_a_400_and_never_reaches_storage()
{
var repo = new Mock<IOpsEventRepository>();
var (status, _) = await RunAsync(repo, "?id=Security_x");
Assert.Equal(HttpStatusCode.BadRequest, status);
repo.Verify(r => r.GetOpsEventAsync(It.IsAny<string>()), Times.Never);
}
private static async Task<(HttpStatusCode Status, JsonElement Body)> RunAsync(Mock<IOpsEventRepository> repo, string query)
{
const string callerTenant = "00000000-0000-0000-0000-000000000001";
var (req, context) = EndpointHarness.Request(callerTenant, queryString: query);
context.Items["ClaimsPrincipal"] = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", callerTenant) }, "test"));
var response = await new GetOpsEventsFunction(NullLogger<GetOpsEventsFunction>.Instance, repo.Object).Run(req);
response.Body.Position = 0;
using var doc = JsonDocument.Parse(response.Body);
return (response.StatusCode, doc.RootElement.Clone());
}
// ── The alert carries the link ────────────────────────────────────────
[Fact]
public async Task Both_the_plain_and_the_payload_send_link_to_the_event()
{
var h = new OpsNotificationChannelsTests.DispatchHarness(
new[]
{
new OpsAlertRule { EventType = "DeviceBlocked", MinSeverity = OpsEventSeverity.Info, NotifyChannelIds = new List<string> { "push" } },
new OpsAlertRule { EventType = "DeviceBlocked", MinSeverity = OpsEventSeverity.Info, NotifyChannelIds = new List<string> { "hook" }, IncludePayload = true },
},
new[]
{
new NotificationChannel { Id = "push", Name = "push", ProviderType = 20, Url = "https://push.example.invalid/hook", Enabled = true },
new NotificationChannel { Id = "hook", Name = "hook", ProviderType = 20, Url = "https://hook.example.invalid/hook", Enabled = true },
});
await h.Sut.DispatchAsync(OpsEventCategory.Security, "DeviceBlocked", OpsEventSeverity.Warning,
"Device blocked", "11111111-1111-1111-1111-111111111111", """{"serialNumber":"SYNTH-0001"}""", SecurityId);
Assert.Equal(2, h.Sends.Count);
Assert.All(h.Sends, s =>
{
var action = Assert.Single(s.Alert.Actions);
Assert.Equal("openUrl", action.Type);
Assert.Equal(Constants.PortalOpsEventUrl(SecurityId), action.Url);
});
}
[Fact]
public void An_alert_without_an_id_has_no_action()
=> Assert.Empty(OpsAlertDispatchService.BuildAlert(OpsEventCategory.Security, "DeviceBlocked",
OpsEventSeverity.Warning, "m", null, null, opsEventId: null).Actions);
[Fact]
public async Task The_service_dispatches_the_id_the_save_assigned()
{
var repo = new Mock<IOpsEventRepository>();
repo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => e.Id = OpsEventId.Format(e.Category, RowKey))
.Returns(Task.CompletedTask);
var h = new OpsNotificationChannelsTests.DispatchHarness(
new[] { new OpsAlertRule { EventType = OpsEventTypes.TenantSignup, MinSeverity = OpsEventSeverity.Info, NotifyChannelIds = new List<string> { "push" } } },
new[] { new NotificationChannel { Id = "push", Name = "push", ProviderType = 20, Url = "https://push.example.invalid/hook", Enabled = true } });
var service = new OpsEventService(repo.Object, NullLogger<OpsEventService>.Instance, h.Sut);
await service.RecordTenantSignupAsync("11111111-1111-1111-1111-111111111111", "admin@contoso.invalid");
// The dispatch is fire-and-forget behind the save; the mocks complete synchronously,
// the short wait only guards against a scheduler hop.
for (var i = 0; i < 50 && h.Sends.Count == 0; i++)
await Task.Delay(10);
var send = Assert.Single(h.Sends);
Assert.Equal(Constants.PortalOpsEventUrl(OpsEventCategory.Tenant + "_" + RowKey), Assert.Single(send.Alert.Actions).Url);
}
[Fact]
public async Task Tenant_signup_points_its_related_page_at_the_tenant()
{
OpsEventEntry? saved = null;
var repo = new Mock<IOpsEventRepository>();
repo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => saved = e)
.Returns(Task.CompletedTask);
var h = new OpsNotificationChannelsTests.DispatchHarness(Array.Empty<OpsAlertRule>(), Array.Empty<NotificationChannel>());
var service = new OpsEventService(repo.Object, NullLogger<OpsEventService>.Instance, h.Sut);
await service.RecordTenantSignupAsync("11111111-1111-1111-1111-111111111111", "admin@contoso.invalid");
using var details = JsonDocument.Parse(saved!.Details!);
// The bare /admin/tenants redirects and drops the query; the section path keeps it.
Assert.Equal("/admin/tenants/management?tenantId=11111111-1111-1111-1111-111111111111",
details.RootElement.GetProperty("portalPath").GetString());
}
}
@@ -1,5 +1,6 @@
using System.Text;
using System.Text.Json;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Functions.Services.Push;
using AutopilotMonitor.Shared;
@@ -193,6 +194,64 @@ public class PushAlertProjectorTests
Assert.Null(PushAlertProjector.BuildTopic(Constants.PortalBaseUrl, Encoding.UTF8.GetBytes("key")));
}
[Fact]
public void Ops_push_links_to_its_event_and_stays_its_own_notification()
{
const string eventId = "Security_2516103215872799164";
var alert = OpsAlertDispatchService.BuildAlert("Security", "ExcessiveSessionEventsAutoActioned", "Critical",
"Auto-block device " + Sentinel, "11111111-1111-1111-1111-111111111111", detailsJson: null, eventId);
var payload = PushAlertProjector.Project(alert, NotificationScope.Platform, "e6", Now);
var json = Encoding.UTF8.GetString(PushAlertProjector.ToJsonBytes(payload));
Assert.Equal(Constants.PortalOpsEventUrl(eventId), payload.PortalUrl);
// Two ops alerts must never replace each other on the lock screen or in the push service queue.
Assert.Equal("e6", payload.Tag);
Assert.Null(PushAlertProjector.BuildTopic(payload.PortalUrl, Encoding.UTF8.GetBytes("key")));
Assert.DoesNotContain(Sentinel, json);
}
[Fact]
public void Only_a_session_link_binds_a_push_to_a_session()
{
Alert(Constants.PortalSessionUrl("sess-1"), out var a, "a");
Alert(Constants.PortalSessionUrl("sess-1"), out var b, "b");
Alert(Constants.PortalBaseUrl + "/admin/backups/detail?id=backup-1", out var backup, "c");
Assert.Equal(a.Tag, b.Tag);
Assert.StartsWith("s-", a.Tag);
Assert.Equal("c", backup.Tag);
Assert.Null(PushAlertProjector.BuildTopic(backup.PortalUrl, Encoding.UTF8.GetBytes("key")));
static void Alert(string url, out PushPayload payload, string entryId)
{
var alert = EnrollmentFailed();
alert.Actions.Clear();
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "Open", Url = url });
payload = PushAlertProjector.Project(alert, Tenant, entryId, Now);
}
}
[Fact]
public void Session_watch_link_names_the_session_tenant_for_a_cross_tenant_watcher()
{
var url = Constants.PortalSessionUrl("sess-1", "22222222-2222-2222-2222-222222222222");
Assert.Equal(Constants.PortalBaseUrl + "/sessions?id=sess-1&tenantId=22222222-2222-2222-2222-222222222222", url);
Assert.NotNull(PushAlertProjector.BuildTopic(url, Encoding.UTF8.GetBytes("key")));
}
[Fact]
public void Hardware_rejected_push_links_to_the_whitelist()
{
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert("Contoso", "Model 1", "ZZ998877");
var payload = PushAlertProjector.Project(alert, Tenant, "e7", Now);
Assert.Equal(Constants.PortalBaseUrl + Constants.PortalHardwareWhitelistPath, payload.PortalUrl);
Assert.Equal("e7", payload.Tag);
}
[Fact]
public void System_messages_never_share_a_tag_so_none_replaces_an_earlier_notification()
{
@@ -50,6 +50,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
};
await _table.UpsertEntityAsync(entity);
entry.Id = OpsEventId.Format(pk, rk);
}
public async Task<OpsEventEntry?> GetOpsEventAsync(string id)
{
if (!OpsEventId.TryParse(id, out var category, out var rowKey))
return null;
var response = await _table.GetEntityIfExistsAsync<TableEntity>(category, rowKey);
return response.HasValue ? MapToEntry(response.Value!) : null;
}
public async Task<List<OpsEventEntry>> GetOpsEventsAsync(
@@ -239,7 +249,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
return new OpsEventEntry
{
Id = $"{entity.PartitionKey}_{entity.RowKey}",
Id = OpsEventId.Format(entity.PartitionKey, entity.RowKey),
Category = entity.PartitionKey,
EventType = entity.GetString("EventType") ?? string.Empty,
Severity = entity.GetString("Severity") ?? OpsEventSeverity.Info,
@@ -14,7 +14,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
/// <summary>
/// Cross-tenant READ endpoint for PLATFORM-OPERATIONAL events (Consent/Maintenance/Security/Tenant/Agent/
/// SLA). Supports optional category + eventType/severity/minSeverity + dateFrom/dateTo filters (all
/// server-side except the tenant drill) and opt-in pagination. Authentication +
/// server-side except the tenant drill) and opt-in pagination, or one event by ?id=. Authentication +
/// GlobalReadOrAdmin + TenantScoping.None enforced by PolicyEnforcementMiddleware — a Global Admin and the
/// read-only Global Reader reach it, but a delegated ("MSP") admin does NOT: ops-events is the platform
/// operator's view, not customer telemetry, so it stays GA/Reader-only (None ⇒ no scoped-route rescue for
@@ -45,6 +45,20 @@ namespace AutopilotMonitor.Functions.Functions.Admin
{
var callerTenantId = TenantHelper.GetTenantId(req);
var query = req.Query;
// ?id= is the deep link of one event (the ops-alert "Open in the portal" action):
// a point read that ignores every other parameter.
var id = query["id"];
if (!string.IsNullOrEmpty(id))
{
if (!OpsEventId.TryParse(id, out _, out _))
return await req.BadRequestAsync("id must be an ops event id ({Category}_{19-digit RowKey})");
var single = await _repository.GetOpsEventAsync(id);
var found = single == null ? new List<OpsEventEntry>() : new List<OpsEventEntry> { single };
return await req.OkAsync(new OpsEventListResponse { Success = true, Count = found.Count, Events = found });
}
var category = query["category"];
var filterTenantId = query["tenantId"];
@@ -7,6 +7,7 @@ using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
@@ -267,7 +268,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
type: "hardware_rejection",
title: "New rejected hardware model",
message: $"{manufacturer} {model} was rejected by the hardware whitelist.",
href: "/settings/tenant/hardware-whitelist");
href: Constants.PortalHardwareWhitelistPath);
}
}
catch (Exception bellEx)
@@ -297,7 +298,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
type: "tpm_pss_unsupported",
title: "Device with incompatible TPM detected",
message: $"Device {serialNumber}{(deviceLabel.Length > 0 ? $" ({deviceLabel})" : "")} reported a TPM that cannot perform RSA-PSS signing — the agent on this device cannot authenticate. A TPM firmware update or device replacement is required.",
href: "/settings/tenant/hardware-whitelist");
href: Constants.PortalHardwareWhitelistPath);
}
}
catch (Exception bellEx)
@@ -193,6 +193,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications
new() { Name = "Serial", Value = serialNumber ?? "\u2013" },
new() { Name = "Data Quality", Value = "Unverified (pre-auth distress signal)" },
},
// No session exists for a rejected device: the whitelist page lists the rejected models.
Actions = new List<NotificationAction>
{
new() { Type = "openUrl", Title = "Open hardware whitelist", Url = Constants.PortalBaseUrl + Constants.PortalHardwareWhitelistPath },
},
};
}
@@ -4,6 +4,7 @@ using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
@@ -33,10 +34,11 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Evaluates alert rules for the given ops event and dispatches to the channels those
/// rules target. Safe to call fire-and-forget — never throws.
/// rules target. Safe to call fire-and-forget — never throws. <paramref name="opsEventId"/>
/// is the stored row's <see cref="OpsEventId"/>; every alert links to it.
/// </summary>
public async Task DispatchAsync(string category, string eventType, string severity,
string message, string? tenantId, string? detailsJson = null)
string message, string? tenantId, string? detailsJson = null, string? opsEventId = null)
{
try
{
@@ -63,14 +65,14 @@ namespace AutopilotMonitor.Functions.Services
if (plain.Count > 0)
{
await _channelDispatcher.SendToChannelsAsync(plain,
BuildAlert(category, eventType, severity, message, tenantId, detailsJson: null),
BuildAlert(category, eventType, severity, message, tenantId, detailsJson: null, opsEventId),
NotificationScope.Platform);
}
if (withPayload.Count > 0)
{
await _channelDispatcher.SendToChannelsAsync(withPayload,
BuildAlert(category, eventType, severity, message, tenantId, detailsJson),
BuildAlert(category, eventType, severity, message, tenantId, detailsJson, opsEventId),
NotificationScope.Platform);
}
@@ -145,8 +147,8 @@ namespace AutopilotMonitor.Functions.Services
internal const int MaxDetailFacts = 12;
internal const int MaxDetailValueLength = 256;
private static NotificationAlert BuildAlert(string category, string eventType,
string severity, string message, string? tenantId, string? detailsJson)
internal static NotificationAlert BuildAlert(string category, string eventType,
string severity, string message, string? tenantId, string? detailsJson, string? opsEventId)
{
var notifSeverity = severity switch
{
@@ -176,6 +178,12 @@ namespace AutopilotMonitor.Functions.Services
facts.AddRange(FlattenDetails(detailsJson));
// The link to the event is not payload: the event's page holds the details a channel
// may not carry (a push carries no message at all), so every alert gets it.
var actions = new List<NotificationAction>();
if (!string.IsNullOrEmpty(opsEventId))
actions.Add(new NotificationAction { Type = "openUrl", Title = "Open in the portal", Url = Constants.PortalOpsEventUrl(opsEventId!) });
return new NotificationAlert
{
// Machine-readable routing key for generic consumers — the same value the
@@ -187,6 +195,7 @@ namespace AutopilotMonitor.Functions.Services
Severity = notifSeverity,
ThemeColor = themeColor,
Facts = facts,
Actions = actions,
DataJson = detailsJson,
};
}
@@ -1295,7 +1295,7 @@ namespace AutopilotMonitor.Functions.Services
public virtual Task RecordTenantSignupAsync(string tenantId, string upn)
=> WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantSignup, OpsEventSeverity.Info,
"New tenant signed up", tenantId, upn,
new { portalPath = "/admin/tenants" });
new { portalPath = $"/admin/tenants/management?tenantId={Uri.EscapeDataString(tenantId)}" });
public virtual Task RecordSessionReportSubmittedAsync(string tenantId, string submittedBy, string sessionId, string reportId)
=> WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.SessionReportSubmitted, OpsEventSeverity.Info,
@@ -1337,7 +1337,8 @@ namespace AutopilotMonitor.Functions.Services
// DispatchAsync has its own top-level try/catch so unobserved exceptions are safe.
// The serialized details ride along: an outbound channel that only knows category,
// event and tenant GUID forces the reader back into the portal for every alert.
_ = _alertDispatch.DispatchAsync(category, eventType, severity, message, tenantId, entry.Details);
// The id the save assigned becomes the alert's link to exactly this event.
_ = _alertDispatch.DispatchAsync(category, eventType, severity, message, tenantId, entry.Details, entry.Id);
}
catch (Exception ex)
{
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -272,13 +273,20 @@ public static class PushAlertProjector
return null;
}
/// <summary>
/// Only a session link binds a push to a session. Any other deep link (an ops event, a
/// whitelist page) names no session, and its pushes must never replace each other.
/// </summary>
private static bool IsSessionUrl([NotNullWhen(true)] string? portalUrl) =>
portalUrl != null && portalUrl.StartsWith(Constants.PortalBaseUrl + "/sessions?id=", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Session-bound alerts collapse per session where the browser honours tags (best effort,
/// K21); everything else is its own notification.
/// </summary>
private static string BuildTag(string? portalUrl, string entryId)
{
if (portalUrl == null || !portalUrl.Contains("id=", StringComparison.Ordinal))
if (!IsSessionUrl(portalUrl))
return entryId;
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(portalUrl));
return "s-" + Convert.ToHexString(digest, 0, 6).ToLowerInvariant();
@@ -291,7 +299,7 @@ public static class PushAlertProjector
/// </summary>
public static string? BuildTopic(string? portalUrl, byte[]? hmacKey)
{
if (portalUrl == null || hmacKey == null || hmacKey.Length == 0 || !portalUrl.Contains("id=", StringComparison.Ordinal))
if (!IsSessionUrl(portalUrl) || hmacKey == null || hmacKey.Length == 0)
return null;
var mac = HMACSHA256.HashData(hmacKey, Encoding.UTF8.GetBytes(portalUrl));
return System.Buffers.Text.Base64Url.EncodeToString(mac.AsSpan(0, 24));
@@ -173,7 +173,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
foreach (var scope in ScopesOf(watch, tenantId))
{
var payload = PushAlertProjector.SystemMessage("session_watch", title, body, severity, scope, now,
Constants.PortalSessionUrl(sessionId));
Constants.PortalSessionUrl(sessionId, tenantId));
await _delivery.SendSystemToOwnerAsync(scope, watch.OwnerObjectId, payload, WatchPushTtl).ConfigureAwait(false);
}
@@ -119,6 +119,24 @@ namespace AutopilotMonitor.Shared
public static string PortalSessionUrl(string sessionId) =>
$"{PortalBaseUrl}/sessions?id={System.Uri.EscapeDataString(sessionId ?? string.Empty)}";
/// <summary>
/// The session deep link for a reader who may sit outside the session's tenant (a Global
/// Admin's session watch): the portal opens the session's tenant directly instead of
/// trying the reader's own first.
/// </summary>
public static string PortalSessionUrl(string sessionId, string tenantId) =>
$"{PortalSessionUrl(sessionId)}&tenantId={System.Uri.EscapeDataString(tenantId ?? string.Empty)}";
/// <summary>
/// Opens one ops event's detail in the Ops page (Global Admin only). Carries the opaque
/// event id only: the details stay behind the portal sign-in.
/// </summary>
public static string PortalOpsEventUrl(string opsEventId) =>
$"{PortalBaseUrl}/admin/ops/?eventId={System.Uri.EscapeDataString(opsEventId ?? string.Empty)}";
/// <summary>The tenant's hardware whitelist settings, which also list the rejected models.</summary>
public const string PortalHardwareWhitelistPath = "/settings/tenant/hardware-whitelist";
/// <summary>
/// The pairing link shown as QR code and typed into the receiver app. The code travels in the
/// fragment (never reaches a server log); the trailing slash matches the static export.
@@ -11,8 +11,18 @@ namespace AutopilotMonitor.Shared.DataAccess
/// </summary>
public interface IOpsEventRepository
{
/// <summary>
/// Writes the event and sets <see cref="OpsEventEntry.Id"/> to the stored row's
/// <see cref="OpsEventId"/>, so the caller can link to exactly this row.
/// </summary>
Task SaveOpsEventAsync(OpsEventEntry entry);
/// <summary>
/// Point read of one event by its <see cref="OpsEventId"/>; null when the id is malformed
/// or the row no longer exists (retention).
/// </summary>
Task<OpsEventEntry?> GetOpsEventAsync(string id);
/// <summary>
/// Returns all matching ops events in the given UTC window. <paramref name="category"/>
/// is optional — when provided, scopes to a single PartitionKey for an
@@ -61,6 +71,45 @@ namespace AutopilotMonitor.Shared.DataAccess
public static readonly string[] All = { Consent, Maintenance, Security, Tenant, Agent, Sla, Platform };
}
/// <summary>
/// The one id shape of an ops event: <c>{Category}_{RowKey}</c>, the RowKey being the 19-digit
/// inverted-tick key. The portal deep link (<c>/admin/ops?eventId=</c>) and the
/// <c>?id=</c> lookup carry it, so it must stay parseable back to PartitionKey + RowKey.
/// </summary>
public static class OpsEventId
{
private const int RowKeyLength = 19;
public static string Format(string category, string rowKey) => $"{category}_{rowKey}";
/// <summary>True for a known category followed by a 19-digit RowKey; nothing else is an id.</summary>
public static bool TryParse(string? id, out string category, out string rowKey)
{
category = string.Empty;
rowKey = string.Empty;
if (string.IsNullOrEmpty(id))
return false;
var separator = id!.LastIndexOf('_');
if (separator <= 0 || id.Length - separator - 1 != RowKeyLength)
return false;
var candidate = id.Substring(0, separator);
if (Array.IndexOf(OpsEventCategory.All, candidate) < 0)
return false;
for (var i = separator + 1; i < id.Length; i++)
{
if (id[i] < '0' || id[i] > '9')
return false;
}
category = candidate;
rowKey = id.Substring(separator + 1);
return true;
}
}
/// <summary>
/// Severity levels for operational events.
/// </summary>
@@ -0,0 +1,32 @@
"use client";
import { useState } from "react";
/** Small inline copy-to-clipboard icon next to an id in the admin detail views. */
export function CopyButton({ value, title = "Copy to clipboard" }: { value: string; title?: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(value).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<button
onClick={handleCopy}
title={title}
className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
@@ -0,0 +1,38 @@
"use client";
import type { CursorPager } from "@/hooks/useCursorPager";
type PagerControls = Pick<CursorPager<unknown>, "pageNumber" | "hasNext" | "hasPrev" | "next" | "prev">;
const BUTTON =
"px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors";
/** Footer of the admin tables paged by the backend (Pattern B1): the row count, the page, Previous and Next. */
export function CursorPagerFooter({
pager,
count,
loading,
filtered = false,
}: {
pager: PagerControls;
count: number;
loading: boolean;
/** The count is a client-side filter of the page, not the page itself. */
filtered?: boolean;
}) {
return (
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">
{count} on page {pager.pageNumber}{filtered ? " (filtered)" : ""}{pager.hasNext ? "" : " (last)"}
</span>
<div className="flex items-center gap-2">
<button onClick={() => void pager.prev()} disabled={!pager.hasPrev || loading} className={BUTTON}>
Previous
</button>
<button onClick={() => void pager.next()} disabled={!pager.hasNext || loading} className={BUTTON}>
Next
</button>
</div>
</div>
);
}
@@ -6,6 +6,7 @@ import { apiErrorText, fetchJson, nullOn404 } from "@/lib/apiClient";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import type { DistressReportEntry, DistressReportListResponse } from "@/lib/generated/wire-types.generated";
import { ModalPortal } from "@/components/ModalPortal";
import { CopyButton } from "./CopyButton";
/** One stored distress report — the wire shape (optional where the agent sent nothing). */
type DistressReport = DistressReportEntry;
@@ -69,34 +70,6 @@ function SortableTh({
);
}
function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(value).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<button
onClick={handleCopy}
title="Copy to clipboard"
className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
export function DistressReportsSection({
getAccessToken,
setError,
@@ -1,17 +1,20 @@
"use client";
import { sessionUrl, deviceBlockUrl } from "@/lib/routes";
import { useEffect, useState } from "react";
import { sessionUrl, deviceBlockUrl, tenantManagementUrl, trustedRoute } from "@/lib/routes";
import { Suspense, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import TruncatedLabel from "@/components/TruncatedLabel";
import { apiErrorText, fetchJson, nullOn404 } from "@/lib/apiClient";
import type { CursorPage } from "@/lib/cursorPager";
import { useCursorPager } from "@/hooks/useCursorPager";
import { extractSessionId, buildAutoReason } from "./opsEventSessionHelpers";
import { extractSessionId, extractPortalPath, buildAutoReason } from "./opsEventSessionHelpers";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type { OpsEventEntry, OpsEventListResponse } from "@/lib/generated/wire-types.generated";
import { ModalPortal } from "@/components/ModalPortal";
import { CursorPagerFooter } from "./CursorPagerFooter";
import { CopyButton } from "./CopyButton";
/** One ops event row (wire shape). */
type OpsEvent = OpsEventEntry;
@@ -85,35 +88,16 @@ function CategoryBadge({ category }: { category: string }) {
);
}
function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(value).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
export function OpsEventsSection(props: OpsEventsSectionProps) {
// useSearchParams() needs a Suspense boundary for the static prerender.
return (
<button
onClick={handleCopy}
title="Copy to clipboard"
className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
<Suspense fallback={null}>
<OpsEventsSectionInner {...props} />
</Suspense>
);
}
export function OpsEventsSection({
function OpsEventsSectionInner({
getAccessToken,
setError,
opsEventRetentionDays,
@@ -146,6 +130,28 @@ export function OpsEventsSection({
return null;
}
};
// Deep link from an ops alert ("Open in the portal"): ?eventId=… opens that event's detail
// from a point read — no dependency on the row being in the current window or page.
const searchParams = useSearchParams();
const eventIdParam = searchParams?.get("eventId") ?? null;
const deepLinkConsumed = useRef(false);
useEffect(() => {
if (deepLinkConsumed.current || !eventIdParam) return;
deepLinkConsumed.current = true;
const run = async () => {
try {
const data = await fetchJson<OpsEventListResponse>(api.opsEvents.get(eventIdParam), getAccessToken);
// Match the id, not the first row: a backend without the ?id= lookup answers with the list.
const event = data.events?.find((e) => e.id === eventIdParam);
if (event) setSelectedEvent(event);
else setError(`This ops event no longer exists — ops events are kept for ${opsEventRetentionDays} days.`);
} catch (err) {
setError(apiErrorText(err, "Failed to load the ops event"));
}
};
void run();
}, [eventIdParam, getAccessToken, setError, opsEventRetentionDays]);
const pager = useCursorPager(fetchEvents);
const { reset } = pager;
const events = pager.items;
@@ -407,27 +413,7 @@ export function OpsEventsSection({
</table>
{/* Pagination (Pattern B1 — backend-driven) */}
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">
{filteredEvents.length} on page {pager.pageNumber}{searchQuery && ` (filtered)`}{pager.hasNext ? "" : " (last)"}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => void pager.prev()}
disabled={!pager.hasPrev || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={() => void pager.next()}
disabled={!pager.hasNext || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
<CursorPagerFooter pager={pager} count={filteredEvents.length} loading={loading} filtered={!!searchQuery} />
</div>
)}
</div>
@@ -501,6 +487,28 @@ export function OpsEventsSection({
)}
</dl>
{(() => {
const tenantUrl = selectedEvent.tenantId ? tenantManagementUrl(selectedEvent.tenantId) : null;
const relatedPath = extractPortalPath(selectedEvent.details ?? null);
// TenantSignup's related page IS the tenant — one button, not two.
const relatedUrl = relatedPath && relatedPath !== tenantUrl ? trustedRoute(relatedPath) : null;
if (!tenantUrl && !relatedUrl) return null;
return (
<div className="mt-5 pt-4 border-t border-gray-200 dark:border-gray-700 flex flex-wrap gap-2">
{tenantUrl && (
<Link href={tenantUrl} className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60 border border-blue-300 dark:border-blue-700">
View tenant
</Link>
)}
{relatedUrl && (
<Link href={relatedUrl} className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60 border border-blue-300 dark:border-blue-700">
Open related page
</Link>
)}
</div>
);
})()}
{(() => {
const sessionId = extractSessionId(selectedEvent.details ?? null);
if (!sessionId) return null;
@@ -512,7 +520,7 @@ export function OpsEventsSection({
</p>
<div className="flex flex-wrap gap-2">
<Link
href={sessionUrl(sessionId)}
href={sessionUrl(sessionId, { tenantId: selectedEvent.tenantId ?? undefined })}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60 border border-blue-300 dark:border-blue-700"
@@ -23,6 +23,8 @@ import type {
RuleSubmissionItem,
RuleSubmissionListResponse,
} from "@/lib/generated/wire-types.generated";
import { CursorPagerFooter } from "./CursorPagerFooter";
import { CopyButton } from "./CopyButton";
const PAGE_SIZE = 20;
const STATUS_FILTERS = ["pending", "approved", "declined", "withdrawn"] as const;
@@ -32,30 +34,6 @@ interface RuleSubmissionsSectionProps {
setError: (error: string | null) => void;
}
function CopyButton({ value, title = "Copy to clipboard" }: { value: string; title?: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(value).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<button onClick={handleCopy} title={title} className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
{copied ? (
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
function StatusBadge({ status }: { status: string }) {
const b = submissionStatusBadge(status);
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${b.cls}`}>{b.label}</span>;
@@ -245,13 +223,7 @@ function RuleSubmissionsSectionInner({ getAccessToken, setError }: RuleSubmissio
))}
</tbody>
</table>
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">{items.length} on page {pager.pageNumber}{pager.hasNext ? "" : " (last)"}</span>
<div className="flex items-center gap-2">
<button onClick={() => void pager.prev()} disabled={!pager.hasPrev || loading} className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">Previous</button>
<button onClick={() => void pager.next()} disabled={!pager.hasNext || loading} className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">Next</button>
</div>
</div>
<CursorPagerFooter pager={pager} count={items.length} loading={loading} />
</div>
)}
</div>
@@ -14,6 +14,8 @@ import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type { SessionReportDownloadUrlResponse, SessionReportListResponse, SessionReportMetadata, UpdateSessionReportNoteRequest } from "@/lib/generated/wire-types.generated";
import { ModalPortal } from "@/components/ModalPortal";
import { CursorPagerFooter } from "./CursorPagerFooter";
import { CopyButton } from "./CopyButton";
const PAGE_SIZE = 20;
@@ -38,34 +40,6 @@ interface SessionReportsSectionProps {
setError: (error: string | null) => void;
}
function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(value).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<button
onClick={handleCopy}
title="Copy to clipboard"
className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
function AdminNoteEditor({
report,
canMutate,
@@ -424,27 +398,7 @@ function SessionReportsSectionInner({
</table>
{/* Pagination (Pattern B1 — backend-driven) */}
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">
{reports.length} on page {pager.pageNumber}{pager.hasNext ? "" : " (last)"}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => void pager.prev()}
disabled={!pager.hasPrev || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={() => void pager.next()}
disabled={!pager.hasNext || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
<CursorPagerFooter pager={pager} count={reports.length} loading={loading} />
</div>
)}
</div>
@@ -1,6 +1,7 @@
// Helpers for the Ops Events detail modal's "Block this device" / "Kill this
// device" deep-link buttons. Lives in a `.ts` file so vitest (which currently
// only loads `.test.ts` and doesn't transform JSX) can import and test them.
// Helpers for the Ops Events detail modal's deep-link buttons (the event's
// session, its device block, its related page). Lives in a `.ts` file so vitest
// (which currently only loads `.test.ts` and doesn't transform JSX) can import
// and test them.
const SESSION_ID_KEYS = ["sessionId", "SessionId", "sessionID", "session_id"];
@@ -19,6 +20,26 @@ export function extractSessionId(detailsJson: string | null): string | null {
}
}
/**
* The page an ops event names as its subject (`portalPath` in the details, e.g. the session
* report or the tenant). Only a same-origin path qualifies: anything a browser could read as
* another origin (`//host`, `/\host`, a scheme) is dropped, since details are free JSON.
*/
export function extractPortalPath(detailsJson: string | null): string | null {
if (!detailsJson) return null;
try {
const parsed = JSON.parse(detailsJson);
if (!parsed || typeof parsed !== "object") return null;
const value = (parsed as Record<string, unknown>).portalPath;
if (typeof value !== "string") return null;
const path = value.trim();
if (!path.startsWith("/") || path.startsWith("//") || /[\\\u0000-\u001f]/.test(path)) return null;
return path;
} catch {
return null;
}
}
export function buildAutoReason(eventType: string, sessionId: string): string {
// Always cap to 8 chars — for GUIDs this is exactly the first hex group, for
// anything else it stays readable in audit-log columns.
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { extractSessionId, buildAutoReason } from "../../app/admin/components/opsEventSessionHelpers";
import { extractSessionId, extractPortalPath, buildAutoReason } from "../../app/admin/components/opsEventSessionHelpers";
import { tenantManagementUrl } from "../routes";
// Locks in the contract that connects the Ops Events detail modal's "Block this
// device" / "Kill this device" deep-link buttons to the Device Block form.
@@ -82,3 +83,32 @@ describe("buildAutoReason", () => {
expect(buildAutoReason("ExcessiveSessionEvents", "abc")).toBe("Excessive session events on session abc");
});
});
// The modal's "Open related page" follows an ops event's details.portalPath. Details are free
// JSON, so only a same-origin path may become a link.
describe("extractPortalPath", () => {
it("returns an in-app path with its query", () => {
expect(extractPortalPath(JSON.stringify({ portalPath: "/admin/reports/session-reports?reportId=r1" })))
.toBe("/admin/reports/session-reports?reportId=r1");
});
it("returns null without a usable portalPath", () => {
expect(extractPortalPath(null)).toBeNull();
expect(extractPortalPath("not json")).toBeNull();
expect(extractPortalPath(JSON.stringify({ sessionId: "s" }))).toBeNull();
expect(extractPortalPath(JSON.stringify({ portalPath: 42 }))).toBeNull();
});
it("refuses anything a browser could resolve to another origin", () => {
for (const portalPath of ["//evil.example", "/\\evil.example", "https://evil.example/x", "javascript:alert(1)", "admin/ops", "/admin\nops"]) {
expect(extractPortalPath(JSON.stringify({ portalPath }))).toBeNull();
}
});
// TenantSignup's portalPath is built by the backend in exactly this shape; the modal shows
// one "View tenant" button instead of two only while both sides agree.
it("matches the tenant link the backend writes for a signup", () => {
const tenantId = "11111111-1111-1111-1111-111111111111";
expect(tenantManagementUrl(tenantId)).toBe(`/admin/tenants/management?tenantId=${tenantId}`);
});
});
+2
View File
@@ -643,6 +643,8 @@ export const api = {
pageSize: opts?.pageSize?.toString(),
continuation: opts?.continuation,
})}`,
/** One event by its id (`{Category}_{RowKey}`); an empty list when it is gone. */
get: (id: string) => `${API_BASE_URL}/api/global/ops-events${qs({ id })}`,
},
// ── Notifications ─────────────────────────────────────────────────────────
@@ -91,6 +91,11 @@ export function deviceBlockUrl(
return withQuery("/admin/security/device-block", { sessionId, reason, action });
}
/** Tenant Management narrowed to one tenant (its search box takes the id). */
export function tenantManagementUrl(tenantId: string): Route {
return withQuery("/admin/tenants/management", { tenantId });
}
export function customsArchiveUrl(tenantId: string, historyRowKey: string): Route {
return withQuery("/admin/customs-archive/detail", {
tenantId,
-5
View File
@@ -3,7 +3,6 @@
"fingerprints": {
"016e952b3d382f92": 1,
"04f18ccf5471e650": 1,
"070f759a953f319a": 1,
"09722ff0399d0079": 1,
"0d52e627a698ed73": 1,
"1102405933d6d36d": 1,
@@ -39,12 +38,10 @@
"654a9df3c383502e": 1,
"6792a21ecd44b52c": 1,
"6a3941fb9f2fdf26": 1,
"71e22c4690beef10": 1,
"7247997f500a7474": 1,
"757cad917cd84ca1": 1,
"7eadf5a3615bfd95": 1,
"8084f14fe903eabb": 1,
"82c45324e90ea66a": 1,
"853f4f98ba80f168": 1,
"8be66cde251a0e1c": 1,
"8d336378a41f94e6": 1,
@@ -63,9 +60,7 @@
"ab6861a9ea29854d": 1,
"af270234f673d876": 1,
"b26235044348e7ba": 1,
"b4fc75e3c24c9138": 1,
"b62f5f7b96567483": 1,
"b90f4d00cd591f40": 1,
"bf3345bef96687f1": 1,
"c20b0ee6fe8ea9bb": 1,
"c69b00e7d0d6e3dd": 1,