Compare commits

...

17 Commits

Author SHA1 Message Date
Oliver Kieselbach e7dd962133 bump pointer: internal (time attribution v4 D-314, OOBE update page agent fixes, backlog B-q3z/B-6dx/B-dbf; member list lesson)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 22:57:10 +02:00
Oliver Kieselbach 14a67ef5af Time attribution shows the OOBE quality update as Windows Update and the wait after its restart as Waiting for sign-in (D-314)
TimeAttributionCalculator v4 cuts two segments out of the phase partition:
os_update from the OobeNDUP page start whose visit holds update evidence
(a servicing step or a name only an update in progress writes) to the last
update evidence before the user is back; awaiting_sign_in from there to the
first evidence of the user, only after a restart. Both replace the phase
span they overlap; without evidence the partition is v3's. OsUpdates names
the KBs and restarts. Reboot gaps no longer start or end on the clock-sync
row logged at boot. The sweep recomputes the last 30 days.

Aggregates carry eight segments; the session lane and Fleet Health show
"Windows Update" (cyan) and "Waiting for sign-in" (sky) with the KBs and
restarts in legend and tooltip; MCP get_time_attribution description;
manifest and wire types regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 22:55:45 +02:00
Oliver Kieselbach 2371855b77 OOBE update page: fields from event properties in every display language, page start and stop never capped, no duplicates after a restart
ShellCoreTracker reads CXID, result, name and value from the event properties
(62404..62407 %1/%2), live and in the backfill. The formatted message is
localized: German devices lost result and value, French devices reported no
update page at all. The text patterns stay as the fallback and accept
guillemets. The Hello-wizard start reads the CXID property too.

OobeUpdateTelemetry: page start and stop sit outside the run budget (at most
3 per page), names share a budget of 150 (was 50; the cloud page logs ~45
init names in its first 40 s). ReportPage returns whether it reported.

A RecordId watermark (shellcore-page-watermark.json) claims every update-page
record and persists reported ones, so the restart backfill does not send the
minutes before the restart again. EspAndHelloTracker passes the state
directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 22:55:39 +02:00
Oliver Kieselbach 3a50de83ef Add-member input keeps a minimum width and wraps instead of shrinking (D-313)
The form no longer lays out by viewport breakpoint: a wide window can host a
narrow column next to the settings sidebar, where the input shrank to a few
characters. The input now grows from an 18rem basis and wraps below the type
switch when the row has no room; role and Add wrap together.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 22:51:28 +02:00
Oliver Kieselbach 24dfd247e9 bump pointer: internal (viewer app role + member list live, D-313, lesson)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 21:18:14 +02:00
Oliver Kieselbach a640039518 Member role changes need an explicit Save; the GA add form gives the input its own line (D-312)
- An open member row keeps role and bootstrap permission as a draft:
  Save writes it, Discard or closing the row drops it, so a stray select
  change no longer alters anyone's access.
- AddMemberForm takes `stacked` for narrow hosts; the GA tenant editor
  modal uses it, so the email field spans the full width there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 21:13:11 +02:00
Oliver Kieselbach c3e9011905 bump pointer: internal (D-312, plan viewer-app-role-and-member-list, B-8gf/B-7w2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 20:36:37 +02:00
Oliver Kieselbach 8f6926a945 Viewer can come from an Entra app role; Access Management lists one line per member (D-312)
- EntraAppRoleResolver maps the Viewer app role next to Admin and Operator
  (the highest role wins); a claim Viewer gets no bootstrap-token rights.
- The tenant settings page and the GA tenant editor share components/members:
  one line per member, a click opens who added it and the actions, Remove
  asks in the row, role chips with counts, a search, 25 rows per page. A
  vanished chip or a shrunken list moves the selection, so the list never
  shows an empty page and never snaps back.
- The add form sits above the list, About Roles is collapsed, and a tenant
  with Entra app roles enabled sees that those users are not listed.
- The EntraAppRolesEnabled doc names Viewer; manifest and generated wire
  types regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 20:32:54 +02:00
Oliver Kieselbach 01323fdf6d ProgressAccessHint test matches the docs link as a substring, not a RegExp
CodeQL js/incomplete-hostname-regexp (alert #96) traced DOCS_URL into the
RegExp built in the "Learn more" assertion. The dots were escaped with
[.], which CodeQL does not recognize as a sanitizer. A substring match
on the exact markup needs no escaping and keeps the same checks: a space
after the sentence, the exact href with target/rel, and "Learn more" as
the link text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 20:17:46 +02:00
Oliver Kieselbach c566ed818b bump pointer: internal (access hint released, B-u5a verify only)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 17:01:38 +02:00
Oliver Kieselbach 3a40361bb6 bump pointer: internal (access-hint follow-up, docs-corpus URL invariant)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:54:52 +02:00
Oliver Kieselbach 1b988d2943 Docs links use the URL GitBook publishes: access hint via the docs path registry, MCP citations from SUMMARY.md groups
GitBook publishes a page under the slug of its SUMMARY.md group, not its
folder: troubleshooting/ is /troubleshooting-and-support/, trust/ is
/trust-and-security/. A folder URL works only for pages that predate the
group rename (307); for a newer page it is a 404.

- Web: the Progress Portal access hint linked the new troubleshooting
  article by folder path (404 live). It now uses DOCS_PATHS.progressPortalOnly.
- MCP: docUrl built folder URLs for every search_docs citation. Live census
  over all 58 SUMMARY pages: 47 direct, 10 via redirect, 1 dead.
  summaryFolderSlugs now derives the segments from SUMMARY.md; all 60 page
  URLs of the real corpus answer 200.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:54:40 +02:00
Oliver Kieselbach e88a0e455c Progress Portal access hint links to its troubleshooting article
Both variants end with a muted "Learn more" to
troubleshooting/progress-portal-only in the customer docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:35:37 +02:00
Oliver Kieselbach 0695f7943c bump pointers: internal (D-311, plan progress-portal-access-hint, B-zr9/B-u5a), commands (platform-health marker)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:20:50 +02:00
Oliver Kieselbach 6f8e7d48b2 Progress Portal tells members without a role whom to ask for access; activation lands every role where sign-in would
A member without a portal role only ever sees the Progress Portal and never
learned that their organization already runs the full product. Below the
search they now see one muted line: ask your Autopilot Monitor admin for a
role. When the organization holds no session and signed up more than 14 days
ago, the line names the signup date and the support mailbox instead.

- GET progress/tenant-status (AuthenticatedUser, JWT tenant only, no session
  data): unused flag plus signup date. Both reads are strict, so a storage
  failure is a 500 and never reads as "unused".
- ISessionRepository.HasAnySessionStrictAsync: key-only probe on
  SessionsIndex with the primary-table fallback, no catch.
- ProgressTenantStatusResponse + wire parity facts; regenerated manifests and
  web/MCP wire types.
- lib/landingTarget.ts: one landing decision for the landing gate and the
  activation page. Leaving activation no longer sends Operators, Viewers,
  Global Readers and delegated admins to the Progress Portal, and it keeps
  the deep link the landing gate saved during activation.
- lib/supportContact.ts: one source for the support address.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:20:38 +02:00
Oliver Kieselbach 0248d14738 bump pointer: internal (IME diff notes 1.106.106.0 + 1.105.154.0, B-4v2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 16:01:05 +02:00
Oliver Kieselbach dc59fea6e5 bump pointer: internal (OOBE update phase: DEV-004 live, agent v2.0.1473 in Shadow)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:28:34 +02:00
61 changed files with 3316 additions and 717 deletions
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
@@ -10,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
@@ -36,18 +38,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
private const string CspGateTrue =
"CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_isNDUPAllowedByCSPSucceeded', Value: 'true'.";
// A French Windows labels the same fields "Résultat", "Nom" and "valeur" and quotes them
// in guillemets; the event properties carry the values unchanged in every language.
private const string FrenchNdupStarted = "Activité d’application web CloudExperienceHost démarrée. CXID : « OobeNDUP ».";
private const string FrenchPageStopped = "Activité d’application web CloudExperienceHost arrêtée. Résultat : « success ».";
private const string FrenchCspGate =
"Événement d’application web CloudExperienceHost 2. Nom : « ExpeditedUpdate_isNDUPAllowedByCSPSucceeded », valeur : « true ».";
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
private IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState> _registry = Array.Empty<OobeUpdateRegistrySnapshot.KeyState>();
public void Dispose() => _tmp.Dispose();
private ShellCoreTracker Build() =>
private ShellCoreTracker Build(string? stateDirectory = null) =>
new ShellCoreTracker("S1", "T1",
new InformationalEventPost(_sink, new VirtualClock(At)),
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
helloTracker: null,
oobeUpdateRegistryReader: () => _registry);
oobeUpdateRegistryReader: () => _registry,
stateDirectory: stateDirectory);
private string StateDirectory => Path.Combine(_tmp.Path, "state");
private static void Live(ShellCoreTracker tracker, int eventId, string description, DateTime at, long recordId) =>
tracker.ProcessEvent(eventId, description, at, "Microsoft-Windows-Shell-Core", isBackfill: false, recordId: recordId);
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
_sink.Posted.Where(p =>
@@ -274,6 +289,132 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
Assert.Equal(3, _sink.Posted.Count);
}
// ------------------------------------------------------------ localization ----
[Fact]
public void LocalizedRecords_AreReadFromTheirProperties()
{
using var tracker = Build();
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStarted, FrenchNdupStarted, At, "Microsoft-Windows-Shell-Core",
isBackfill: false, properties: new[] { "OobeNDUP" });
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEvent, FrenchCspGate, At.AddSeconds(5), "Microsoft-Windows-Shell-Core",
isBackfill: false, properties: new[] { "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true" });
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppStopped, FrenchPageStopped, At.AddMinutes(1), "Microsoft-Windows-Shell-Core",
isBackfill: false, properties: new[] { "success" });
var pages = ByType(Constants.EventTypes.OobeUpdatePage).Select(Data).ToList();
Assert.Equal(new object[] { "page_started", "event_name", "page_stopped" }, pages.Select(d => d["cxhEvent"]).ToArray());
Assert.Equal("OobeNDUP", pages[0]["page"]);
Assert.Equal("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", pages[1]["name"]);
Assert.Equal("true", pages[1]["value"]);
Assert.Equal("OobeNDUP", pages[2]["page"]);
Assert.Equal("success", pages[2]["result"]);
}
[Fact]
public void LocalizedMessage_WithoutProperties_DoesNotYieldItsLabelledFields()
{
// Why the properties are read: the French labels are not the manifest's.
Assert.Null(ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppStopped, FrenchPageStopped, properties: null).Primary);
Assert.Null(ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppEvent, FrenchCspGate, properties: null).Primary);
Assert.Equal("success",
ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppStopped, FrenchPageStopped, new[] { "success" }).Primary);
}
[Theory]
[InlineData("CloudExperienceHost Web App Event 2. Name: 'ExpeditedUpdate_x', Value: 'true'.")]
[InlineData("CloudExperienceHost Web App Event 2. Name : « ExpeditedUpdate_x », Value : « true ».")]
[InlineData("CloudExperienceHost Web App Event 2. Name\u00A0: «\u202FExpeditedUpdate_x\u202F», Value\u00A0: «\u00A0true\u00A0».")]
public void MessageFallback_AcceptsQuotesAndGuillemets(string description)
{
var fields = ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppEvent, description, properties: null);
Assert.Equal("ExpeditedUpdate_x", fields.Primary);
Assert.Equal("true", fields.Value);
}
[Fact]
public void PropertyValue_LeavesTheTrackerOnlyInASafeForm()
{
var fields = ShellCoreTracker.ReadCxhFields(ShellCoreTracker.EventId_ShellCore_WebAppEvent, "",
new[] { "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded", "{\"url\":\"https://login.microsoftonline.com/x\"}" });
var record = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, fields, lastPage: null);
Assert.Equal("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded", record!.Name);
Assert.Null(record.Value);
Assert.DoesNotContain("microsoftonline",
ShellCoreTracker.FormatCxhBreadcrumb(ShellCoreTracker.EventId_ShellCore_WebAppEvent, fields, lastPage: null));
}
// ------------------------------------------------------- restart watermark ----
[Fact]
public void RestartBackfill_SkipsUpdatePageRecords_AnEarlierRunReported()
{
using (var first = Build(StateDirectory))
{
Live(first, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 500);
Live(first, ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(1), recordId: 501);
}
// The restart reads the minutes before it again — plus the stop the first run missed.
using var second = Build(StateDirectory);
second.LoadPageWatermark();
second.ReplayBackfillRecords(new[]
{
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 500),
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddMinutes(1), recordId: 501),
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(2), recordId: 502),
});
var pages = ByType(Constants.EventTypes.OobeUpdatePage).Select(Data).ToList();
Assert.Equal(new object[] { "page_started", "event_name", "page_stopped" }, pages.Select(d => d["cxhEvent"]).ToArray());
Assert.Equal(new object[] { false, false, true }, pages.Select(d => d["backfill"]).ToArray());
// The skipped start still names the page the stop belongs to.
Assert.Equal("OobeNDUP", pages[2]["page"]);
}
[Fact]
public void SameRecord_FromTheLiveWatcherAndTheBackfill_IsReportedOnce()
{
// The live watcher is armed before the backfill runs, so both can deliver a record.
using var tracker = Build(StateDirectory);
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 700);
tracker.ReplayBackfillRecords(new[]
{
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: 700),
});
Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage));
}
[Fact]
public void Watermark_FollowsReportedRecordsOnly()
{
using var tracker = Build(StateDirectory);
for (var i = 0; i <= OobeUpdateTelemetry.MaxPageEventsPerKey; i++)
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, At.AddSeconds(i), recordId: 800 + i);
// The repeat over the per-name cap is claimed for this run, not written down.
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey, ByType(Constants.EventTypes.OobeUpdatePage).Count);
var state = JsonConvert.DeserializeObject<EventRecordWatermark.WatermarkState>(
File.ReadAllText(Path.Combine(StateDirectory, ShellCoreTracker.PageWatermarkStateFileName)));
Assert.Equal(800 + OobeUpdateTelemetry.MaxPageEventsPerKey - 1, state!.LastRecordId);
}
[Fact]
public void RecordsWithoutARecordId_AreNotDeduplicated()
{
using var tracker = Build(StateDirectory);
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, recordId: -1);
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(5), recordId: -1);
Assert.Equal(2, ByType(Constants.EventTypes.OobeUpdatePage).Count);
Assert.False(File.Exists(Path.Combine(StateDirectory, ShellCoreTracker.PageWatermarkStateFileName)));
}
[Fact]
public void AgentStartAndStop_ReportTheRegistryState_StopOnce()
{
@@ -84,6 +84,55 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
Assert.False(raised);
}
[Fact]
public void ProcessEvent_62404_NGC_in_a_localized_message_is_read_from_the_cxid_property()
{
// A French Windows quotes the CXID in guillemets — the "'NGC'" substring never matched.
using var tmp = new TempDirectory();
using var tracker = MakeTracker(tmp, new VirtualClock(ClockNow));
var raised = 0;
tracker.HelloWizardStarted += (_, _) => raised++;
tracker.ProcessEvent(
eventId: ShellCoreTracker.EventId_ShellCore_WebAppStarted,
description: "Activité d’application web CloudExperienceHost démarrée. CXID : « NGC ».",
timestamp: ClockNow,
providerName: "Microsoft-Windows-Shell-Core",
isBackfill: false,
properties: new[] { "NGC" });
Assert.Equal(1, raised);
}
[Theory]
[InlineData("AADHello", true)]
[InlineData("NGC", true)]
[InlineData("NGCPin", false)]
[InlineData("OobeNDUP", false)]
public void HelloWizardStart_is_decided_by_the_cxid(string cxid, bool expected)
{
Assert.Equal(expected, ShellCoreTracker.IsHelloWizardStart(cxid, description: "CXID: 'AADHello'"));
}
[Fact]
public void Backfill_62404_reads_the_cxid_property()
{
using var tmp = new TempDirectory();
using var tracker = MakeTracker(tmp, new VirtualClock(ClockNow));
var captured = new List<HelloWizardStartedEventArgs>();
tracker.HelloWizardStarted += (_, args) => captured.Add(args);
var historical = ClockNow.AddMinutes(-3);
tracker.ReplayBackfillRecords(new[]
{
new ShellCoreRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted,
"Activité d’application web CloudExperienceHost démarrée. CXID : « NGC ».", historical,
recordId: 42, properties: new[] { "NGC" }),
});
Assert.Equal(historical, Assert.Single(captured).OccurredAtUtc);
}
[Fact]
public void Backfill_62404_raises_once_with_historical_timestamp()
{
@@ -112,6 +112,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun, ByType(Constants.EventTypes.OobeUpdatePage).Count);
}
[Fact]
public void PageStartAndStop_AreReported_AfterTheNameBudgetIsSpent()
{
// The time attribution reads the update's span from the page start and stop.
var telemetry = Build();
for (var i = 0; i < OobeUpdateTelemetry.MaxPageEventsPerRun; i++)
{
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false));
}
Assert.False(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_late"), At, isBackfill: false));
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, 62404, page: "RebootNDUP"), At.AddMinutes(1), isBackfill: false));
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "RebootNDUP", result: "success"), At.AddMinutes(2), isBackfill: false));
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun + 2, pages.Count);
Assert.Equal(new object[] { "page_started", "page_stopped" },
pages.Skip(OobeUpdateTelemetry.MaxPageEventsPerRun).Select(p => Data(p)["cxhEvent"]).ToArray());
}
[Fact]
public void SamePageStart_IsReportedAtMostThreeTimes()
{
// Outside the run budget, but a page that restarts in a loop still cannot flood.
var telemetry = Build();
var reported = Enumerable.Range(0, 10)
.Count(i => telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, 62404, page: "OobeNDUP"), At.AddMinutes(i), isBackfill: false));
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey, reported);
}
// ------------------------------------------------------------ oobe_update_state ----
[Fact]
@@ -413,7 +413,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_tenantId,
_post,
_logger,
_helloTracker);
_helloTracker,
stateDirectory: _stateDirectory);
_shellCoreTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
_shellCoreTracker.WhiteGloveCompleted += OnWhiteGloveCompleted;
_shellCoreTracker.EspFailureDetected += OnShellCoreEspFailureDetected;
@@ -53,7 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// </summary>
internal sealed class OobeUpdateTelemetry
{
internal const int MaxPageEventsPerRun = 50;
internal const int MaxPageEventsPerRun = 150;
internal const int MaxPageEventsPerKey = 3;
internal const int MaxStateEventsPerRun = 6;
internal const int MaxStateLineLength = 1000;
@@ -98,31 +98,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// <summary>
/// One <c>oobe_update_page</c> per update-page record, at most
/// <see cref="MaxPageEventsPerKey"/> per page/name and <see cref="MaxPageEventsPerRun"/>
/// per run — a page that logs the same name in a loop cannot flood the session.
/// <see cref="MaxPageEventsPerKey"/> per page/name — a page that logs the same name in a
/// loop cannot flood the session. Event names also share <see cref="MaxPageEventsPerRun"/>
/// per run; a page start or stop is outside that budget, because the time attribution
/// reads the update's span from them. True when the record was reported.
/// </summary>
public void ReportPage(OobeUpdatePageRecord record, DateTime occurredAtUtc, bool isBackfill)
public bool ReportPage(OobeUpdatePageRecord record, DateTime occurredAtUtc, bool isBackfill)
{
if (record == null) return;
if (record == null) return false;
var isEventName = record.CxhEvent == OobeUpdatePageRecord.EventName;
var key = $"{record.CxhEvent}:{record.Page ?? record.Name}";
int occurrence;
lock (_lock)
{
if (_pageEvents >= MaxPageEventsPerRun)
if (isEventName && _pageEvents >= MaxPageEventsPerRun)
{
if (!_pageCapLogged)
{
_pageCapLogged = true;
_logger.Info($"OOBE update page telemetry: {MaxPageEventsPerRun} events reached, the rest of this run stays in agent.log");
_logger.Info($"OOBE update page telemetry: {MaxPageEventsPerRun} event names reached, the rest of this run stays in agent.log");
}
return;
return false;
}
_pageEventsPerKey.TryGetValue(key, out occurrence);
if (occurrence >= MaxPageEventsPerKey) return;
if (occurrence >= MaxPageEventsPerKey) return false;
occurrence++;
_pageEventsPerKey[key] = occurrence;
_pageEvents++;
if (isEventName) _pageEvents++;
}
var data = new Dictionary<string, object>
@@ -153,10 +156,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
Data = data,
ImmediateUpload = false,
});
return true;
}
catch (Exception ex)
{
_logger.Debug($"OOBE update page telemetry failed: {ex.Message}");
return false;
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
@@ -62,17 +63,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
@"OOBE_ESP.*Exiting", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Manifest templates: 62404 "... started. CXID: '%1'.", 62405 "... stopped. Result: '%1'.",
// 62406 "... Event 1. Name: '%1'.", 62407 "... Event 2. Name: '%1', Value: '%2'.". A 62407
// value can carry URLs with tenant ids — a value leaves the tracker only in a form that
// cannot (SafeValuePattern: a boolean, an integer, an HRESULT).
// 62406 "... Event 1. Name: '%1'.", 62407 "... Event 2. Name: '%1', Value: '%2'.". The
// fields are read from the event PROPERTIES (%1/%2): the formatted message is localized
// ("Ergebnis: '…'", "Nom : « … »"), so these patterns are only the fallback for a record
// without properties and accept « » and a space before the colon. A 62407 value can carry
// URLs with tenant ids — a value leaves the tracker only in a form that cannot
// (SafeValuePattern: a boolean, an integer, an HRESULT).
private static readonly Regex CxidPattern = new Regex(
@"CXID:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
@"CXID\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
private static readonly Regex ResultPattern = new Regex(
@"Result:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
@"Result\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
private static readonly Regex NamePattern = new Regex(
@"Name:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
@"Name\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
private static readonly Regex ValuePattern = new Regex(
@"Value:\s*'([^']*)'", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
@"Value\s*:\s*['\u00AB]\s*([^'\u00BB]*?)\s*['\u00BB]", RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
private static readonly Regex SafeValuePattern = new Regex(
@"^(?:true|false|-?[0-9]{1,10}|0x[0-9a-f]{1,8})$",
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
@@ -84,6 +88,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly InformationalEventPost _post;
private readonly HelloTracker _helloTracker;
private readonly OobeUpdateTelemetry _oobeUpdate;
// Shell-Core RecordIds of the update-page records already reported: the restart backfill
// must not report a record an earlier run already sent (D-310 field data: the same 20
// records came twice after a restart).
private readonly EventRecordWatermark _pageWatermark;
internal const string PageWatermarkStateFileName = "shellcore-page-watermark.json";
private EventLogWatcher _watcher;
private int _started;
@@ -131,7 +140,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
InformationalEventPost post,
AgentLogger logger,
HelloTracker helloTracker,
Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> oobeUpdateRegistryReader = null)
Func<IReadOnlyList<OobeUpdateRegistrySnapshot.KeyState>> oobeUpdateRegistryReader = null,
string stateDirectory = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
@@ -141,17 +151,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// Test seam: a fake registry reader; production reads the real registry.
_oobeUpdate = new OobeUpdateTelemetry(sessionId, tenantId, post, logger,
oobeUpdateRegistryReader ?? OobeUpdateRegistrySnapshot.Read);
_pageWatermark = new EventRecordWatermark(stateDirectory, PageWatermarkStateFileName, logger, "Shell-Core update page");
}
internal bool IsEspExitedForTest { get { lock (_stateLock) { return _espExitDetected; } } }
internal bool IsWhiteGloveDetectedForTest { get { lock (_stateLock) { return _whiteGloveDetected; } } }
/// <summary>Loads the update-page watermark as <see cref="Start"/> does, without arming the watcher.</summary>
internal void LoadPageWatermark() => _pageWatermark.Load();
public void Start()
{
// The OOBE update registry state at every agent start — so also right after the
// update's restart (D-310).
if (Interlocked.Exchange(ref _started, 1) == 0)
{
_oobeUpdate.ReportState(OobeUpdateTelemetry.Moments.AgentStart);
_pageWatermark.Load();
}
try
{
@@ -185,7 +202,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
// Stop runs again from Dispose — one snapshot, and only for a tracker that started.
if (Volatile.Read(ref _started) == 1 && Interlocked.Exchange(ref _stopReported, 1) == 0)
{
_oobeUpdate.ReportState(OobeUpdateTelemetry.Moments.AgentStop);
}
if (_watcher == null) return;
try
@@ -216,7 +235,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
var description = record.FormatDescription() ?? $"Event ID {record.Id}";
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
ProcessEvent(record.Id, description, timestamp, record.ProviderName ?? "", isBackfill: false);
ProcessEvent(record.Id, description, timestamp, record.ProviderName ?? "", isBackfill: false,
recordId: record.RecordId ?? -1, properties: ReadProperties(record));
}
catch (Exception ex)
{
@@ -228,9 +248,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// Core event-processing logic. Exposed as internal so tests can drive it without
/// needing to synthesize an <see cref="EventRecord"/> (abstract + Windows-only).
/// </summary>
internal void ProcessEvent(int eventId, string description, DateTime timestamp, string providerName, bool isBackfill)
internal void ProcessEvent(
int eventId,
string description,
DateTime timestamp,
string providerName,
bool isBackfill,
long recordId = -1,
IReadOnlyList<string> properties = null)
{
ObserveCxhRecord(eventId, description, timestamp, isBackfill);
var fields = ReadCxhFields(eventId, description, properties);
ObserveCxhRecord(eventId, fields, timestamp, isBackfill, recordId);
if (eventId == EventId_ShellCore_WebAppStopped || eventId == EventId_ShellCore_WebAppEventName) return;
string eventType;
@@ -244,7 +272,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
switch (eventId)
{
case EventId_ShellCore_WebAppStarted: // 62404
if (description.Contains("AADHello") || description.Contains("'NGC'"))
if (IsHelloWizardStart(fields.Primary, description))
{
eventType = Constants.EventTypes.HelloWizardStarted;
message = "Windows Hello wizard started (CloudExperienceHost)";
@@ -437,7 +465,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
PathType.LogName,
$"*[System[{WatchedEventIdsXPath} and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
var records = new List<(int Id, string Description, DateTime OccurredAtUtc)>();
var records = new List<ShellCoreRecord>();
using (var reader = new EventLogReader(query))
{
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
@@ -449,7 +477,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// (EspAndHelloTrackerAdapter) can stamp signals with the source time
// rather than collapsing to wall-clock-now.
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
records.Add((record.Id, description, timestamp));
records.Add(new ShellCoreRecord(record.Id, description, timestamp,
record.RecordId ?? -1, ReadProperties(record)));
}
}
}
@@ -502,6 +531,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// </summary>
internal void ReplayBackfillRecords(
IReadOnlyList<(int Id, string Description, DateTime OccurredAtUtc)> records)
{
if (records == null) return;
var converted = new List<ShellCoreRecord>(records.Count);
foreach (var r in records) converted.Add(new ShellCoreRecord(r.Id, r.Description, r.OccurredAtUtc));
ReplayBackfillRecords(converted);
}
internal void ReplayBackfillRecords(IReadOnlyList<ShellCoreRecord> records)
{
if (records == null || records.Count == 0) return;
@@ -513,11 +550,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
foreach (var record in records)
{
var description = record.Description ?? string.Empty;
ObserveCxhRecord(record.Id, description, record.OccurredAtUtc, isBackfill: true);
var fields = ReadCxhFields(record.Id, description, record.Properties);
ObserveCxhRecord(record.Id, fields, record.OccurredAtUtc, isBackfill: true, recordId: record.RecordId);
if (record.Id == EventId_ShellCore_WebAppStarted)
{
HandleBackfillRecord(record.Id, description, record.OccurredAtUtc);
HandleBackfillRecord(record.Id, description, record.OccurredAtUtc, fields.Primary);
continue;
}
@@ -586,7 +624,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// (<c>record.TimeCreated</c>); subscribers read it via
/// <see cref="LastEventOccurredAtUtc"/> during their synchronous event handler.
/// </summary>
internal void HandleBackfillRecord(int eventId, string description, DateTime occurredAtUtc)
internal void HandleBackfillRecord(int eventId, string description, DateTime occurredAtUtc, string cxid = null)
{
if (eventId == EventId_ShellCore_WebAppStarted)
{
@@ -594,7 +632,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// are unrelated. Fire-once so a replayed log tail cannot re-raise (downstream is
// idempotent anyway: HelloTracker once-guard, adapter dedup flag, engine
// set-once fact — this guard just keeps the noise down).
if (!description.Contains("AADHello") && !description.Contains("'NGC'")) return;
if (!IsHelloWizardStart(cxid ?? ReadCxhFields(eventId, description, null).Primary, description)) return;
bool shouldRaiseWizard;
lock (_stateLock)
@@ -628,28 +666,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// Writes one agent.log line per CloudExperienceHost page start/stop and per update-page
/// event name, so a diagnostics package shows the OOBE navigation. Records of the update
/// page itself (<c>OobeNDUP</c>, <c>RebootNDUP</c> and its <c>ExpeditedUpdate_*</c> names)
/// are also reported as <c>oobe_update_page</c>, and at the page's live start and stop the
/// registry state behind it as <c>oobe_update_state</c>. No decision.
/// are also reported as <c>oobe_update_page</c> — once across agent runs, by RecordId — and
/// at the page's live start and stop the registry state behind it as
/// <c>oobe_update_state</c>. No decision.
/// </summary>
private void ObserveCxhRecord(int eventId, string description, DateTime occurredAtUtc, bool isBackfill)
private void ObserveCxhRecord(int eventId, CxhFields fields, DateTime occurredAtUtc, bool isBackfill, long recordId)
{
try
{
string page;
lock (_stateLock)
{
if (eventId == EventId_ShellCore_WebAppStarted)
_lastCxhPage = ExtractToken(CxidPattern, description) ?? _lastCxhPage;
if (eventId == EventId_ShellCore_WebAppStarted && fields.Primary != null)
_lastCxhPage = fields.Primary;
page = _lastCxhPage;
}
var crumb = FormatCxhBreadcrumb(eventId, description, page);
var crumb = FormatCxhBreadcrumb(eventId, fields, page);
if (crumb == null) return;
_logger.Info(isBackfill ? $"{crumb} (backfill, at {occurredAtUtc:o})" : crumb);
var record = ParseUpdatePageRecord(eventId, description, page);
var record = ParseUpdatePageRecord(eventId, fields, page);
if (record == null) return;
_oobeUpdate.ReportPage(record, occurredAtUtc, isBackfill);
// A record an earlier run (or this one) already reported — the restart backfill
// re-reads the minutes before the restart.
if (!_pageWatermark.TryClaim(recordId)) return;
// Only a reported record moves the state file — at most the run budget plus the
// page boundaries; one over the budget stays claimed for this run only.
if (_oobeUpdate.ReportPage(record, occurredAtUtc, isBackfill))
_pageWatermark.MarkEmitted(recordId);
// The registry is read now — only a live page boundary is the page's moment.
if (!isBackfill && record.CxhEvent != OobeUpdatePageRecord.EventName)
@@ -667,32 +713,74 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
/// <summary>The own fields of a CloudExperienceHost record: CXID, result or name (%1) and the 62407 value (%2).</summary>
internal readonly struct CxhFields
{
public CxhFields(string primary, string value)
{
Primary = primary;
Value = value;
}
/// <summary>62404: CXID; 62405: result; 62406/62407: event name. Null when not readable.</summary>
public string Primary { get; }
/// <summary>62407: the raw value (never logged or sent unless it is of a safe form).</summary>
public string Value { get; }
}
/// <summary>
/// The record's fields from its event properties, which carry them verbatim in every
/// display language. Without properties (a test, a record that failed to render them) the
/// formatted message is parsed — English punctuation and « » only, so a localized label
/// ("Ergebnis", "Nom") yields null there.
/// </summary>
internal static CxhFields ReadCxhFields(int eventId, string description, IReadOnlyList<string> properties)
{
if (properties != null && properties.Count > 0)
{
return new CxhFields(
Bound(properties[0]),
eventId == EventId_ShellCore_WebAppEvent && properties.Count > 1 ? Bound(properties[1]) : null);
}
switch (eventId)
{
case EventId_ShellCore_WebAppStarted:
return new CxhFields(ExtractToken(CxidPattern, description), null);
case EventId_ShellCore_WebAppStopped:
return new CxhFields(ExtractToken(ResultPattern, description), null);
case EventId_ShellCore_WebAppEventName:
return new CxhFields(ExtractToken(NamePattern, description), null);
case EventId_ShellCore_WebAppEvent:
return new CxhFields(ExtractToken(NamePattern, description), ExtractToken(ValuePattern, description));
default:
return default;
}
}
/// <summary>
/// The breadcrumb for one Shell-Core record, or null when it is not one: a page start
/// (62404, its CXID), a page stop (62405, its result, attributed to the last started
/// page), or an update-page event name (62406/62407 — a 62407 value only in a safe form).
/// </summary>
internal static string FormatCxhBreadcrumb(int eventId, string description, string lastPage)
internal static string FormatCxhBreadcrumb(int eventId, string description, string lastPage) =>
FormatCxhBreadcrumb(eventId, ReadCxhFields(eventId, description, null), lastPage);
internal static string FormatCxhBreadcrumb(int eventId, CxhFields fields, string lastPage)
{
switch (eventId)
{
case EventId_ShellCore_WebAppStarted:
{
var cxid = ExtractToken(CxidPattern, description);
return cxid == null ? null : $"CXH page started: {cxid}";
}
return fields.Primary == null ? null : $"CXH page started: {fields.Primary}";
case EventId_ShellCore_WebAppStopped:
{
var result = ExtractToken(ResultPattern, description) ?? "?";
return $"CXH page stopped: {lastPage ?? "?"} (result={result})";
}
return $"CXH page stopped: {lastPage ?? "?"} (result={fields.Primary ?? "?"})";
case EventId_ShellCore_WebAppEventName:
case EventId_ShellCore_WebAppEvent:
{
var name = ExtractToken(NamePattern, description);
if (!IsUpdatePageToken(name)) return null;
var value = eventId == EventId_ShellCore_WebAppEvent ? ExtractSafeValue(description) : null;
return value == null ? $"CXH event: {name}" : $"CXH event: {name} (value={value})";
if (!IsUpdatePageToken(fields.Primary)) return null;
var value = eventId == EventId_ShellCore_WebAppEvent ? SafeValue(fields.Value) : null;
return value == null ? $"CXH event: {fields.Primary}" : $"CXH event: {fields.Primary} (value={value})";
}
default:
return null;
@@ -704,29 +792,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// the start of a page whose CXID names NDUP, the stop of such a page (62405, attributed
/// to the last started page), or an update-page event name from 62406/62407.
/// </summary>
internal static OobeUpdatePageRecord ParseUpdatePageRecord(int eventId, string description, string lastPage)
internal static OobeUpdatePageRecord ParseUpdatePageRecord(int eventId, string description, string lastPage) =>
ParseUpdatePageRecord(eventId, ReadCxhFields(eventId, description, null), lastPage);
internal static OobeUpdatePageRecord ParseUpdatePageRecord(int eventId, CxhFields fields, string lastPage)
{
switch (eventId)
{
case EventId_ShellCore_WebAppStarted:
{
var cxid = ExtractToken(CxidPattern, description);
return IsUpdatePageToken(cxid)
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, eventId, page: cxid)
return IsUpdatePageToken(fields.Primary)
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, eventId, page: fields.Primary)
: null;
}
case EventId_ShellCore_WebAppStopped:
return IsUpdatePageToken(lastPage)
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, eventId,
page: lastPage, result: ExtractToken(ResultPattern, description) ?? "?")
page: lastPage, result: fields.Primary ?? "?")
: null;
case EventId_ShellCore_WebAppEventName:
case EventId_ShellCore_WebAppEvent:
{
var name = ExtractToken(NamePattern, description);
if (!IsUpdatePageToken(name)) return null;
var value = eventId == EventId_ShellCore_WebAppEvent ? ExtractSafeValue(description) : null;
return new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, eventId, name: name, value: value);
if (!IsUpdatePageToken(fields.Primary)) return null;
var value = eventId == EventId_ShellCore_WebAppEvent ? SafeValue(fields.Value) : null;
return new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, eventId, name: fields.Primary, value: value);
}
default:
return null;
@@ -738,14 +825,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
&& (token.IndexOf("NDUP", StringComparison.OrdinalIgnoreCase) >= 0
|| token.IndexOf("ExpeditedUpdate", StringComparison.OrdinalIgnoreCase) >= 0);
/// <summary>
/// The Hello wizard's CloudExperienceHost start: CXID <c>AADHello</c> or <c>NGC</c>. With
/// no readable CXID the formatted message decides, as before (an English or German
/// message quotes the CXID as <c>'NGC'</c>).
/// </summary>
internal static bool IsHelloWizardStart(string cxid, string description) =>
cxid != null
? cxid.Contains("AADHello") || string.Equals(cxid, "NGC", StringComparison.Ordinal)
: description != null && (description.Contains("AADHello") || description.Contains("'NGC'"));
/// <summary>
/// The 62407 value when it is a boolean, an integer of up to ten digits or an HRESULT —
/// forms that cannot carry a URL or a tenant id (the CSP gate, error codes). Null for
/// anything else.
/// </summary>
internal static string ExtractSafeValue(string description)
internal static string ExtractSafeValue(string description) =>
SafeValue(ReadCxhFields(EventId_ShellCore_WebAppEvent, description, null).Value);
internal static string SafeValue(string value)
{
var value = ExtractToken(ValuePattern, description);
if (string.IsNullOrEmpty(value)) return null;
try
{
@@ -763,9 +862,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
try
{
var match = pattern.Match(description);
if (!match.Success) return null;
var token = match.Groups[1].Value;
return token.Length > MaxBreadcrumbTokenLength ? token.Substring(0, MaxBreadcrumbTokenLength) + "…" : token;
return match.Success ? Bound(match.Groups[1].Value) : null;
}
catch (RegexMatchTimeoutException)
{
@@ -773,6 +870,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
private static string Bound(string token)
{
if (string.IsNullOrEmpty(token)) return null;
return token.Length > MaxBreadcrumbTokenLength ? token.Substring(0, MaxBreadcrumbTokenLength) + "…" : token;
}
/// <summary>The event's property values as strings (the manifest's %1, %2, …); null when unreadable.</summary>
private static IReadOnlyList<string> ReadProperties(EventRecord record)
{
try
{
var properties = record.Properties;
if (properties == null || properties.Count == 0) return null;
var values = new string[properties.Count];
for (var i = 0; i < properties.Count; i++)
values[i] = Convert.ToString(properties[i]?.Value, CultureInfo.InvariantCulture);
return values;
}
catch (Exception)
{
return null;
}
}
// =====================================================================
// Pattern helpers
// =====================================================================
@@ -812,4 +933,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
return "Unknown_ESP_Failure";
}
}
/// <summary>One Shell-Core record read by the restart backfill.</summary>
internal readonly struct ShellCoreRecord
{
public ShellCoreRecord(int id, string description, DateTime occurredAtUtc, long recordId = -1, IReadOnlyList<string> properties = null)
{
Id = id;
Description = description;
OccurredAtUtc = occurredAtUtc;
RecordId = recordId;
Properties = properties;
}
public int Id { get; }
public string Description { get; }
public DateTime OccurredAtUtc { get; }
/// <summary>The channel's RecordId; -1 when unknown (no cross-run dedup).</summary>
public long RecordId { get; }
/// <summary>The event's property values (%1, %2, …); null when unknown.</summary>
public IReadOnlyList<string> Properties { get; }
}
}
@@ -455,6 +455,25 @@ public class AuthFunctionTests
Assert.Equal(Constants.TenantRoles.Operator, (string)body.role);
}
[Fact]
public void AutoAdmin_NotTriggered_ForClaimDerivedViewer_WhenNoTableMembers()
{
// Same guard for the read-only claim role: an Entra app-role Viewer in a claim-only tenant
// stays a Viewer and is never written into the table as Admin.
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: ViewerRole(), // came from the "roles" claim
mcpCheck: McpDenied(),
hasTenantAdmins: false,
TenantId, Upn, DisplayName, ObjectId);
Assert.True(result.IsSuccess);
Assert.False(result.NeedsAutoAdmin);
var body = ToDynamic(result.Body);
Assert.False((bool)body.isTenantAdmin);
Assert.Equal(Constants.TenantRoles.Viewer, (string)body.role);
}
[Fact]
public void AutoAdmin_NotTriggered_WhenUserAlreadyAdmin()
{
@@ -25,7 +25,37 @@ public class EntraAppRoleResolverTests
[Fact]
public void MapClaimRole_UnknownRole_ReturnsNull()
{
Assert.Null(EntraAppRoleResolver.MapClaimRole(new[] { "Viewer", "SomethingElse" }));
// Neither a foreign value nor an application permission ever becomes a member role.
Assert.Null(EntraAppRoleResolver.MapClaimRole(new[] { "SomethingElse", "access_as_application", "GlobalAdmin" }));
}
[Fact]
public void MapClaimRole_Viewer_IsReadOnlyWithoutBootstrapPermission()
{
var role = EntraAppRoleResolver.MapClaimRole(new[] { "SomethingElse", Constants.TenantRoles.Viewer });
Assert.NotNull(role);
Assert.Equal(Constants.TenantRoles.Viewer, role!.Role);
Assert.False(role.CanManageBootstrapTokens);
}
[Fact]
public void MapClaimRole_OperatorOutranksViewer_WhenBothPresent()
{
var role = EntraAppRoleResolver.MapClaimRole(
new[] { Constants.TenantRoles.Viewer, Constants.TenantRoles.Operator });
Assert.Equal(Constants.TenantRoles.Operator, role!.Role);
}
[Fact]
public void MapClaimRole_AdminOutranksViewer_WhenBothPresent()
{
var role = EntraAppRoleResolver.MapClaimRole(
new[] { Constants.TenantRoles.Viewer, Constants.TenantRoles.Admin });
Assert.Equal(Constants.TenantRoles.Admin, role!.Role);
Assert.True(role.CanManageBootstrapTokens);
}
[Fact]
@@ -63,6 +93,7 @@ public class EntraAppRoleResolverTests
var role = EntraAppRoleResolver.MapClaimRole(new[] { "admin" });
Assert.Equal(Constants.TenantRoles.Admin, role!.Role);
Assert.Equal(Constants.TenantRoles.Viewer, EntraAppRoleResolver.MapClaimRole(new[] { " viewer " })!.Role);
}
// -------------------------------------------------------------------------
@@ -106,14 +137,43 @@ public class EntraAppRoleResolverTests
}
[Fact]
public void Resolve_NoTableRow_IgnoresClaim_WhenTenantFlagDisabled()
public void Resolve_NoTableRow_FallsBackToViewerClaim_WhenEnabled()
{
var result = EntraAppRoleResolver.Resolve(
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: false);
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Viewer }, appRolesEnabled: true);
Assert.Equal(Constants.TenantRoles.Viewer, result!.Role);
}
[Fact]
public void Resolve_DisabledTableRow_IgnoresViewerClaim()
{
var result = EntraAppRoleResolver.Resolve(
TableMemberState.Disabled, null, new[] { Constants.TenantRoles.Viewer }, appRolesEnabled: true);
Assert.Null(result);
}
[Fact]
public void Resolve_EnabledTableViewer_WinsOverHigherClaim()
{
var tableRole = new MemberRoleInfo { Role = Constants.TenantRoles.Viewer };
var result = EntraAppRoleResolver.Resolve(
TableMemberState.Enabled, tableRole, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: true);
Assert.Equal(Constants.TenantRoles.Viewer, result!.Role);
}
[Fact]
public void Resolve_NoTableRow_IgnoresClaim_WhenTenantFlagDisabled()
{
Assert.Null(EntraAppRoleResolver.Resolve(
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Admin }, appRolesEnabled: false));
Assert.Null(EntraAppRoleResolver.Resolve(
TableMemberState.NotPresent, null, new[] { Constants.TenantRoles.Viewer }, appRolesEnabled: false));
}
[Fact]
public void Resolve_NoTableRow_NoClaim_ReturnsNull()
{
@@ -0,0 +1,142 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Progress;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The Progress Portal's tenant status: when an organization reads as unused (no session, signed up
/// longer than <see cref="ProgressTenantStatusFunction.UnusedAfter"/> ago), and that a failed read never
/// does.
/// </summary>
public class ProgressTenantStatusFunctionTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private static readonly DateTime Now = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc);
private static TenantConfiguration Config(DateTime? onboardedAt)
=> new() { TenantId = TenantId, OnboardedAt = onboardedAt };
// ── Policy catalog ───────────────────────────────────────────────────────
[Fact]
public void Route_is_AuthenticatedUser_scoped_to_the_JWT_tenant()
{
// Members without a role are the audience, so the tier must not require membership; the route
// takes no tenant parameter, so no caller can ask about another organization.
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "progress/tenant-status");
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.AuthenticatedUser, entry!.Policy);
Assert.Equal(TenantScoping.Jwt, entry.TenantScoping);
}
// ── Evaluate ─────────────────────────────────────────────────────────────
[Fact]
public void No_session_and_signed_up_a_month_ago_is_unused()
{
var signedUp = Now.AddDays(-30);
var status = ProgressTenantStatusFunction.Evaluate(Config(signedUp), hasAnySession: false, Now);
Assert.True(status.Success);
Assert.True(status.Unused);
Assert.Equal(signedUp, status.SignedUpAt);
}
[Fact]
public void Any_session_means_in_use()
{
var status = ProgressTenantStatusFunction.Evaluate(Config(Now.AddDays(-300)), hasAnySession: true, Now);
Assert.False(status.Unused);
}
[Fact]
public void An_organization_still_being_set_up_is_not_unused()
{
var status = ProgressTenantStatusFunction.Evaluate(Config(Now.AddDays(-13)), hasAnySession: false, Now);
Assert.False(status.Unused);
}
[Fact]
public void The_window_closes_after_exactly_fourteen_days()
{
var status = ProgressTenantStatusFunction.Evaluate(
Config(Now - ProgressTenantStatusFunction.UnusedAfter), hasAnySession: false, Now);
Assert.True(status.Unused);
}
[Fact]
public void A_row_without_a_signup_date_is_old_and_reports_no_date()
{
var status = ProgressTenantStatusFunction.Evaluate(Config(onboardedAt: null), hasAnySession: false, Now);
Assert.True(status.Unused);
Assert.Null(status.SignedUpAt);
}
[Fact]
public void Without_a_configuration_row_nothing_is_unused()
{
var status = ProgressTenantStatusFunction.Evaluate(config: null, hasAnySession: false, Now);
Assert.True(status.Success);
Assert.False(status.Unused);
Assert.Null(status.SignedUpAt);
}
// ── HTTP ─────────────────────────────────────────────────────────────────
private static (ProgressTenantStatusFunction Sut, Mock<ISessionRepository> Sessions) Build(TenantConfiguration? row)
{
var configService = new Mock<TenantConfigurationService>(
Mock.Of<IConfigRepository>(), Mock.Of<ILogger<TenantConfigurationService>>(), Mock.Of<IMemoryCache>())
{ CallBase = false };
configService.Setup(s => s.GetConfigurationIfExistsAsync(TenantId)).ReturnsAsync(row);
var sessions = new Mock<ISessionRepository>(MockBehavior.Strict);
var sut = new ProgressTenantStatusFunction(
Mock.Of<ILogger<ProgressTenantStatusFunction>>(), configService.Object, sessions.Object);
return (sut, sessions);
}
[Fact]
public async Task Answers_for_the_callers_own_tenant()
{
var (sut, sessions) = Build(Config(DateTime.UtcNow.AddDays(-60)));
sessions.Setup(s => s.HasAnySessionStrictAsync(TenantId)).ReturnsAsync(false);
var (req, _) = EndpointHarness.Request(TenantId);
var response = await sut.GetTenantStatus(req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
response.Body.Position = 0;
using var doc = JsonDocument.Parse(response.Body);
Assert.True(doc.RootElement.GetProperty("unused").GetBoolean());
}
[Fact]
public async Task A_failed_session_read_is_an_error_never_unused()
{
var (sut, sessions) = Build(Config(DateTime.UtcNow.AddDays(-60)));
sessions.Setup(s => s.HasAnySessionStrictAsync(TenantId))
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
var (req, _) = EndpointHarness.Request(TenantId);
var response = await sut.GetTenantStatus(req);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
}
@@ -137,4 +137,27 @@ public class ProgressWireParityTests
Events = events
});
}
// GET /api/progress/tenant-status — typed from the start; the anonymous side is the literal the
// portal reads.
[Fact]
public void ProgressTenantStatus_unused_with_signup_date()
{
var signedUpAt = new DateTime(2026, 3, 12, 8, 30, 0, DateTimeKind.Utc);
ApiResponseWireParityTests.AssertWireIdentical(
new { success = true, unused = true, signedUpAt = signedUpAt },
new ProgressTenantStatusResponse { Success = true, Unused = true, SignedUpAt = signedUpAt });
}
// An unknown signup date drops the key on both sides (WhenWritingNull).
[Fact]
public void ProgressTenantStatus_without_signup_date_omits_the_key()
{
DateTime? signedUpAt = null;
ApiResponseWireParityTests.AssertWireIdentical(
new { success = true, unused = false, signedUpAt = signedUpAt },
new ProgressTenantStatusResponse { Success = true, Unused = false, SignedUpAt = signedUpAt });
}
}
@@ -87,4 +87,15 @@ public class TenantMemberRoleResolverTests
Assert.Equal(Constants.TenantRoles.Operator, role?.Role);
}
[Fact]
public async Task NoRow_ViewerClaim_IsAReadOnlyMember_WhenTheTenantOptedIn()
{
AppRolesEnabled();
var role = await _sut.ResolveAsync(Tenant, Upn, new[] { Constants.TenantRoles.Viewer });
Assert.Equal(Constants.TenantRoles.Viewer, role?.Role);
Assert.False(role!.CanManageBootstrapTokens);
}
}
@@ -0,0 +1,490 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Golden fixtures for the OOBE quality update in the time attribution (AttributionVersion 4,
/// D-314): <c>os_update</c> from the update page start whose visit holds update evidence to the
/// last update evidence before the user is back, then — after a restart — <c>awaiting_sign_in</c>
/// until the user is back. Sequences follow the field sessions of 2026-10-02 (synthetic times, no
/// customer data). EVERY fixture asserts the exact-partition invariant.
/// </summary>
public class TimeAttributionOsUpdateTests
{
private static readonly DateTime T0 = new(2026, 10, 2, 14, 0, 0, DateTimeKind.Utc);
private const string Lcu = "Package_for_RollupFix~31bf3856ad364e35~amd64~~26100.6899.1.7";
private const string LcuKb = "Package_for_KB5129195~31bf3856ad364e35~amd64~~26100.6899.1.7";
private static long _seq;
private static EnrollmentEvent Evt(
DateTime ts, string eventType,
EnrollmentPhase phase = EnrollmentPhase.Unknown,
Dictionary<string, object>? data = null)
=> new()
{
EventType = eventType,
Timestamp = ts,
Sequence = ++_seq,
Phase = phase,
Data = data ?? new Dictionary<string, object>(),
};
private static EnrollmentEvent PageStart(DateTime ts, string page) =>
Evt(ts, "oobe_update_page", data: new() { ["cxhEvent"] = "page_started", ["page"] = page });
private static EnrollmentEvent PageStop(DateTime ts, string page, string result) =>
Evt(ts, "oobe_update_page", data: new() { ["cxhEvent"] = "page_stopped", ["page"] = page, ["result"] = result });
private static EnrollmentEvent PageName(DateTime ts, string name, string? value = null)
{
var data = new Dictionary<string, object> { ["cxhEvent"] = "event_name", ["name"] = name };
if (value != null) data["value"] = value;
return Evt(ts, "oobe_update_page", data: data);
}
private static EnrollmentEvent Servicing(DateTime ts, string step, string package = LcuKb, string targetState = "Installed") =>
Evt(ts, "windows_update_servicing", data: new()
{
["step"] = step,
["package"] = package,
["client"] = "UpdateAgentLCU",
["targetState"] = targetState,
});
/// <summary>A restart: the agent's next run detects it; the gap runs from the last event before the boot to the first after.</summary>
private static EnrollmentEvent RebootDetected(DateTime detectedAt, DateTime lastBoot) =>
Evt(detectedAt, "system_reboot_detected", data: new() { ["lastBootUtc"] = lastBoot.ToString("o") });
private static TimeAttributionInput Input(
List<EnrollmentEvent> events, DateTime completedAt, int durationSeconds,
string status = "Succeeded", bool wg = false, DateTime? resumedAt = null)
=> new()
{
TenantId = "00000000-0000-0000-0000-0000000000t1",
SessionId = "00000000-0000-0000-0000-0000000000s1",
Status = status,
StartedAt = T0,
CompletedAt = completedAt,
DurationSeconds = durationSeconds,
IsPreProvisioned = wg,
ResumedAt = resumedAt,
Events = events,
};
private static void AssertExactPartition(SessionTimeBreakdown b)
{
Assert.Equal(b.WallClockSeconds, b.Segments.Sum(s => s.Seconds) + b.UnattributedSeconds);
Assert.True(b.UnattributedSeconds >= 0, "unattributed must never be negative");
}
private static int Total(SessionTimeBreakdown b, string segment) =>
b.GetSegmentTotals().TryGetValue(segment, out var seconds) ? seconds : 0;
private static DateTime At(int minutes, int seconds = 0) => T0.AddMinutes(minutes).AddSeconds(seconds);
// ── the device ESP and the update page, shared by the fixtures ──────────
private static List<EnrollmentEvent> DeviceEsp() => new()
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(At(20), "esp_exiting"),
};
// ── golden: long update, three restarts, second page visit (field: 83 min) ──
private static List<EnrollmentEvent> LongUpdateWithSecondVisit()
{
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 5), "OobeNDUP"),
PageName(At(20, 10), "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true"),
// The agent declares AccountSetup while the page still runs — no evidence of the user.
Evt(At(22, 36), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Servicing(At(43, 35), "initiating", targetState: "Staged"),
Servicing(At(63), "state_reached", targetState: "Staged"),
Servicing(At(63, 10), "initiating"),
Servicing(At(86, 46), "reboot_required"),
PageName(At(102, 39), "ExpeditedUpdate_downloadInstallFailureHelper"),
PageStart(At(103, 11), "RebootNDUP"),
RebootDetected(At(104, 43), lastBoot: At(104)),
Evt(At(106), "agent_shutting_down"),
RebootDetected(At(108), lastBoot: At(107)),
Evt(At(110), "agent_shutting_down"),
RebootDetected(At(112), lastBoot: At(111)),
Servicing(At(119, 54), "state_reached"),
PageStart(At(120, 6), "OobeNDUP"),
PageStop(At(120, 36), "OobeNDUP", "success"),
Evt(At(122, 8), "desktop_arrived"),
Evt(At(125), "enrollment_complete"),
});
return events;
}
[Fact]
public void LongUpdate_IsOneUpdateSegment_FromThePageStartToTheSecondVisit_ThenTheWait()
{
var b = TimeAttributionCalculator.Compute(Input(LongUpdateWithSecondVisit(), At(125), 7500))!;
AssertExactPartition(b);
Assert.Equal(4, b.AttributionVersion);
Assert.Equal(120, Total(b, TimeAttributionSegments.DevicePrep));
Assert.Equal(18 * 60 + 5, Total(b, TimeAttributionSegments.EspApps)); // +2m → page start
Assert.Equal(100 * 60 + 31, Total(b, TimeAttributionSegments.OsUpdate)); // page start → second visit ends
Assert.Equal(92, Total(b, TimeAttributionSegments.AwaitingSignIn)); // → desktop
Assert.Equal(0, Total(b, TimeAttributionSegments.IdentityHello)); // the early AccountSetup lay inside the update
Assert.Equal(172, Total(b, TimeAttributionSegments.DesktopHandoff));
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(20, 5), update.StartUtc);
Assert.Equal(At(120, 36), update.EndUtc);
Assert.Equal(100 * 60 + 31, update.Seconds);
Assert.Equal(new[] { "KB5129195" }, update.Kbs);
Assert.Equal(3, update.RebootCount);
// The update's restarts sit in its own segment.
Assert.Equal(3, b.RebootSpans.Count);
Assert.All(b.RebootSpans, r => Assert.Equal(TimeAttributionSegments.OsUpdate, r.SegmentKey));
}
[Fact]
public void LongUpdate_WasIdentityHelloBeforeV4()
{
// Same stream without the update signals = what v3 saw: the AccountSetup declaration
// took the whole update.
var withoutUpdate = LongUpdateWithSecondVisit()
.Where(e => e.EventType != "oobe_update_page" && e.EventType != "windows_update_servicing")
.ToList();
var b = TimeAttributionCalculator.Compute(Input(withoutUpdate, At(125), 7500))!;
AssertExactPartition(b);
Assert.Equal(0, Total(b, TimeAttributionSegments.OsUpdate));
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn));
Assert.Equal((122 * 60 + 8) - (22 * 60 + 36), Total(b, TimeAttributionSegments.IdentityHello));
Assert.Empty(b.OsUpdates);
}
// ── golden: update inside the apps phase, long wait with standby (field: 48 min) ──
[Fact]
public void UpdateBeforeAccountSetup_TakesItsTimeFromTheAppsPhase_AndTheWaitHoldsTheStandby()
{
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 4), "OobeNDUP"),
PageName(At(21), "ExpeditedUpdate_startWUScanStarted"),
Servicing(At(30), "initiating", package: Lcu, targetState: "Staged"),
Servicing(At(38, 50), "reboot_required", package: Lcu),
PageStart(At(38, 55), "RebootNDUP"),
RebootDetected(At(40, 10), lastBoot: At(39, 30)),
Evt(At(41), "agent_shutting_down"),
RebootDetected(At(42, 30), lastBoot: At(42)),
Servicing(At(43), "state_reached", package: Lcu),
// The user is away: the device sleeps 45 minutes at the lock screen.
Evt(At(89), "system_sleep_episode", data: new()
{
["enteredAt"] = At(44).ToString("o"),
["exitedAt"] = At(89).ToString("o"),
["kind"] = "modern_standby",
}),
Evt(At(90, 50), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Evt(At(91), "hello_wizard_started"),
Evt(At(91), "esp_phase_changed", EnrollmentPhase.FinalizingSetup),
Evt(At(93), "desktop_arrived"),
Evt(At(95), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(95), 5700))!;
AssertExactPartition(b);
Assert.Equal(18 * 60 + 4, Total(b, TimeAttributionSegments.EspApps)); // +2m → page start only
Assert.Equal(22 * 60 + 56, Total(b, TimeAttributionSegments.OsUpdate)); // → servicing reports Installed
// The wait ends at the first evidence of the user: the Hello wizard, not the AccountSetup row.
Assert.Equal((91 - 43) * 60, Total(b, TimeAttributionSegments.AwaitingSignIn));
Assert.Equal(120, Total(b, TimeAttributionSegments.IdentityHello)); // Hello → desktop
var update = Assert.Single(b.OsUpdates);
Assert.Empty(update.Kbs); // RollupFix names no KB
Assert.Equal(2, update.RebootCount);
var sleep = Assert.Single(b.SleepSpans);
Assert.Equal(TimeAttributionSegments.AwaitingSignIn, sleep.SegmentKey);
}
// ── no update, no segment ───────────────────────────────────────────────
private static List<EnrollmentEvent> WithAccountPhase(IEnumerable<EnrollmentEvent> updatePart)
{
var events = DeviceEsp();
events.AddRange(updatePart);
events.AddRange(new[]
{
Evt(At(21), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Evt(At(26), "hello_wizard_started"),
Evt(At(26), "esp_phase_changed", EnrollmentPhase.FinalizingSetup),
Evt(At(28), "desktop_arrived"),
Evt(At(30), "enrollment_complete"),
});
// Canonical order = event time here: the update part was created first.
var ordered = events.OrderBy(e => e.Timestamp).ToList();
foreach (var evt in ordered) evt.Sequence = ++_seq;
return ordered;
}
[Fact]
public void CancelledPage_LeavesThePhasePartitionUnchanged()
{
// AllowOOBEUpdates=0: the page checks the CSP gate and cancels within seconds.
var cancelled = WithAccountPhase(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(20, 3), "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "false"),
PageStop(At(20, 5), "OobeNDUP", "cancel"),
PageStart(At(20, 6), "RebootNDUP"),
});
var plain = WithAccountPhase(Array.Empty<EnrollmentEvent>());
var b = TimeAttributionCalculator.Compute(Input(cancelled, At(30), 1800))!;
var v3 = TimeAttributionCalculator.Compute(Input(plain, At(30), 1800))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
Assert.Equal(v3.Segments.Select(s => (s.SegmentKey, s.Seconds)), b.Segments.Select(s => (s.SegmentKey, s.Seconds)));
}
[Fact]
public void ScanThatFoundNothing_IsNoUpdate()
{
// Every visit logs the same init names — with "Install" and "Download" in them.
var events = WithAccountPhase(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(20, 3), "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true"),
PageName(At(20, 4), "SdxWebAppCloudNDUP_initialize_NDUPInstallCanceledInOptOut", "false"),
PageName(At(20, 4), "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", "0"),
PageName(At(20, 9), "ExpeditedUpdate_startWUScanStarted"),
PageName(At(20, 58), "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
PageStop(At(20, 59), "OobeNDUP", "cancel"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
Assert.Equal(0, Total(b, TimeAttributionSegments.OsUpdate));
}
[Fact]
public void ServicingBesideACancelledPage_IsSomeOtherUpdate()
{
// The page was seen and did not update; a servicing step during the account phase is
// not the OOBE update.
var events = WithAccountPhase(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageStop(At(20, 5), "OobeNDUP", "cancel"),
Servicing(At(23), "initiating", targetState: "Staged"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
}
[Fact]
public void UpdateAfterTheDesktop_StaysATimelineEvent()
{
var events = WithAccountPhase(new[]
{
Servicing(At(28, 30), "initiating", targetState: "Staged"),
Servicing(At(29, 30), "state_reached", targetState: "Staged"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
AssertExactPartition(b);
Assert.Empty(b.OsUpdates);
Assert.Equal(120, Total(b, TimeAttributionSegments.DesktopHandoff));
}
// ── update without a restart: no wait ──────────────────────────────────
[Fact]
public void UpdateWithoutRestart_HasNoWaitForSignIn()
{
// Without a restart the user who started the enrollment is still signed in.
var events = WithAccountPhase(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
PageName(At(20, 40), "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
PageStop(At(20, 50), "OobeNDUP", "success"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(30), 1800))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(0, update.RebootCount);
Assert.Equal(48, Total(b, TimeAttributionSegments.OsUpdate)); // page start → page stop
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn));
}
// ── the page start was not observed ─────────────────────────────────────
[Fact]
public void WithoutAnObservedPageStart_TheFirstServicingStepAfterTheEspExitBeginsTheUpdate()
{
var events = DeviceEsp();
events.AddRange(new[]
{
Servicing(At(10), "initiating", targetState: "Staged"), // during the ESP: not the OOBE update
Servicing(At(25), "initiating", targetState: "Staged"),
Servicing(At(35), "reboot_required"),
PageStart(At(35, 10), "RebootNDUP"),
RebootDetected(At(37), lastBoot: At(36)),
Servicing(At(38), "state_reached"),
Evt(At(40), "desktop_arrived"),
Evt(At(42), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(42), 2520))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(25), update.StartUtc);
Assert.Equal(At(38), update.EndUtc);
Assert.Equal(120, Total(b, TimeAttributionSegments.AwaitingSignIn));
Assert.Equal(23 * 60, Total(b, TimeAttributionSegments.EspApps)); // the hole until the first step stays the phase's
}
[Theory]
[InlineData("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted", true)]
[InlineData("SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded", true)]
[InlineData("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired_lcu", true)]
[InlineData("SdxWebAppCloudNDUP_updateUSOProgressBar_DownloadPhase_progress100", true)]
[InlineData("SdxWebAppCloudNDUP_downloadInstallFailureHelper", true)]
[InlineData("SdxWebAppCloudNDUP_rebootCountdown_starting", true)]
[InlineData("SdxWebAppCloudNDUP_initialize_NDUPInstallCanceledInOptOut", false)]
[InlineData("SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", false)]
[InlineData("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededNoReboot", false)]
[InlineData("SdxWebAppCloudNDUP_handleInstallHelper", false)]
[InlineData("ExpeditedUpdate_getUpdateResultsSucceeded", false)]
[InlineData(null, false)]
public void UpdateActivityNames_AreTheOnesOnlyAnUpdateWrites(string? name, bool expected)
{
Assert.Equal(expected, TimeAttributionCalculator.IsUpdateActivityName(name));
}
// ── restarts ────────────────────────────────────────────────────────────
[Fact]
public void ClockSyncAtBoot_DoesNotShortenTheRestart()
{
// The hardware clock sync is logged 0.1 s before lastBootUtc; the restart began with
// the last real event before it.
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
Servicing(At(25), "initiating", targetState: "Staged"),
Servicing(At(30), "reboot_required"),
PageStart(At(30, 5), "RebootNDUP"),
Evt(At(31).AddMilliseconds(-140), "system_clock_changed"),
RebootDetected(At(31, 20), lastBoot: At(31)),
Servicing(At(32), "state_reached"),
Evt(At(34), "desktop_arrived"),
Evt(At(35), "enrollment_complete"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(35), 2100))!;
AssertExactPartition(b);
var restart = Assert.Single(b.RebootSpans);
Assert.Equal(At(30, 5), restart.StartUtc); // not the clock sync
Assert.Equal(At(31, 20), restart.EndUtc);
Assert.Equal(75, restart.Seconds);
Assert.Equal(TimeAttributionSegments.OsUpdate, restart.SegmentKey);
}
// ── robustness ──────────────────────────────────────────────────────────
[Fact]
public void BackfillDuplicates_ChangeNothing()
{
var once = LongUpdateWithSecondVisit();
var twice = LongUpdateWithSecondVisit();
// An agent restart re-sends the page records it read before, with their event times.
twice.AddRange(twice.Where(e => e.EventType == "oobe_update_page").Select(e => Evt(e.Timestamp, e.EventType, data: e.Data)).ToList());
var a = TimeAttributionCalculator.Compute(Input(once, At(125), 7500))!;
var b = TimeAttributionCalculator.Compute(Input(twice, At(125), 7500))!;
AssertExactPartition(b);
Assert.Equal(a.Segments.Select(s => (s.SegmentKey, s.Seconds)), b.Segments.Select(s => (s.SegmentKey, s.Seconds)));
Assert.Equal(a.OsUpdates.Single().Seconds, b.OsUpdates.Single().Seconds);
}
[Fact]
public void FailedDeclaration_EndsTheUpdateToo()
{
var events = DeviceEsp();
events.AddRange(new[]
{
PageStart(At(20, 2), "OobeNDUP"),
Servicing(At(25), "initiating", targetState: "Staged"),
Evt(At(50), "enrollment_failed", EnrollmentPhase.Failed),
Servicing(At(55), "state_reached", targetState: "Staged"),
});
var b = TimeAttributionCalculator.Compute(Input(events, At(60), 3600, status: "Failed"))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(At(25), update.EndUtc); // the step after the failure is not attributed
Assert.Equal(4 * 60 + 58, Total(b, TimeAttributionSegments.OsUpdate));
Assert.Equal(0, Total(b, TimeAttributionSegments.AwaitingSignIn));
Assert.True(b.UnattributedSeconds >= 10 * 60); // the post-failure tail stays unattributed
}
[Fact]
public void WhiteGlove_UpdateInTheUserPart_StaysInsideItsWindow()
{
var part1End = At(30);
var resumedAt = T0.AddDays(2);
var completedAt = resumedAt.AddMinutes(40);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(At(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(part1End, "whiteglove_part1_complete"),
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.DeviceSetup),
Evt(resumedAt.AddMinutes(3), "esp_exiting"),
PageStart(resumedAt.AddMinutes(3).AddSeconds(4), "OobeNDUP"),
Servicing(resumedAt.AddMinutes(10), "initiating", targetState: "Staged"),
Servicing(resumedAt.AddMinutes(20), "reboot_required"),
PageStart(resumedAt.AddMinutes(20).AddSeconds(5), "RebootNDUP"),
RebootDetected(resumedAt.AddMinutes(23), lastBoot: resumedAt.AddMinutes(22)),
Servicing(resumedAt.AddMinutes(24), "state_reached"),
Evt(resumedAt.AddMinutes(30), "esp_phase_changed", EnrollmentPhase.AccountSetup),
Evt(resumedAt.AddMinutes(32), "hello_wizard_started"),
Evt(resumedAt.AddMinutes(38), "desktop_arrived"),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, durationSeconds: 30 * 60 + 40 * 60, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
var update = Assert.Single(b.OsUpdates);
Assert.Equal(resumedAt.AddMinutes(3).AddSeconds(4), update.StartUtc);
Assert.Equal(resumedAt.AddMinutes(24), update.EndUtc);
Assert.Equal(8 * 60, Total(b, TimeAttributionSegments.AwaitingSignIn)); // → Hello wizard
Assert.All(b.Segments, s => Assert.True(s.EndUtc <= part1End || s.StartUtc >= resumedAt, "no span in the pause"));
}
}
@@ -51,6 +51,10 @@ public class TimeAttributionPersistenceAndAggregationTests
{
new() { StartUtc = T0.AddMinutes(20), EndUtc = T0.AddMinutes(26), Seconds = 360, SegmentKey = TimeAttributionSegments.EspApps, Kind = "modern_standby" },
},
OsUpdates = new List<OsUpdateSpan>
{
new() { StartUtc = T0.AddMinutes(9), EndUtc = T0.AddMinutes(19), Seconds = 600, Kbs = new List<string> { "KB5129195", "KB5054156" }, RebootCount = 2 },
},
BlockingApps = new List<BlockingAppInterval>
{
new() { AppId = AppX, AppName = "App X", StartUtc = T0.AddMinutes(6), EndUtc = T0.AddMinutes(16), Seconds = 600 },
@@ -89,12 +93,30 @@ public class TimeAttributionPersistenceAndAggregationTests
Assert.Equal(T0.AddMinutes(20), sleep.StartUtc);
Assert.Equal(360, sleep.Seconds);
var update = Assert.Single(mapped.OsUpdates);
Assert.Equal(T0.AddMinutes(9), update.StartUtc);
Assert.Equal(T0.AddMinutes(19), update.EndUtc);
Assert.Equal(600, update.Seconds);
Assert.Equal(new[] { "KB5129195", "KB5054156" }, update.Kbs);
Assert.Equal(2, update.RebootCount);
var app = Assert.Single(mapped.BlockingApps);
Assert.Equal(AppX, app.AppId);
Assert.Equal("App X", app.AppName);
Assert.Equal(600, app.Seconds);
}
[Fact]
public void BreakdownEntity_WrittenBeforeOsUpdates_MapsToAnEmptyList()
{
// v3 rows have no OsUpdatesJson column; the sweep recomputes them, reads must not fail.
var entity = TableStorageService.BuildSessionTimeBreakdownEntity(
new SessionTimeBreakdown { TenantId = TenantA, SessionId = "s-1", WallClockSeconds = 600 });
entity.Remove("OsUpdatesJson");
Assert.Empty(TableStorageService.MapToSessionTimeBreakdown(entity).OsUpdates);
}
[Fact]
public void BreakdownEntity_OccupancyTriState_AbsentColumnMapsToNull_NeverZero()
{
@@ -376,9 +398,12 @@ public class TimeAttributionPersistenceAndAggregationTests
pairs, Array.Empty<SessionSummary>(), T0)
.Single(a => a.TenantId == TenantA);
Assert.Equal(6, aggregate.SegmentStats.Count); // 5 canonical + unattributed — the full stack
Assert.Equal(8, aggregate.SegmentStats.Count); // 7 canonical + unattributed — the full stack
// The fixture has no user_esp span → honest 0, not absent.
Assert.Equal(0, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.UserEsp).MedianSeconds);
// A session without an OOBE update contributes 0 to the update and the wait.
Assert.Equal(0, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.OsUpdate).MedianSeconds);
Assert.Equal(0, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.AwaitingSignIn).MedianSeconds);
Assert.Equal(42, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.Unattributed).MedianSeconds);
}
@@ -57,6 +57,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<RawPage<SessionSummary>> GetSessionsPageAsync(string tenantId, int? days, int pageSize, string? continuation)
=> _storage.GetSessionsPageAsync(tenantId, days, pageSize, continuation);
public Task<bool> HasAnySessionStrictAsync(string tenantId)
=> _storage.HasAnySessionStrictAsync(tenantId);
public Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation, IReadOnlyCollection<string>? allowedTenantIds = null, IEnumerable<string>? select = null, CancellationToken cancellationToken = default)
=> _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation, allowedTenantIds, select, cancellationToken);
@@ -0,0 +1,75 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Progress;
/// <summary>
/// Tells the Progress Portal whether the caller's own organization looks unused. A member without a
/// role only ever sees the portal; without this hint they never learn that their organization already
/// runs the product, or that nobody looks after it anymore. Reveals no session data: the JWT tenant
/// only, a flag and the signup date.
/// </summary>
public class ProgressTenantStatusFunction
{
/// <summary>A younger organization is still being set up, not abandoned.</summary>
internal static readonly TimeSpan UnusedAfter = TimeSpan.FromDays(14);
private readonly ILogger<ProgressTenantStatusFunction> _logger;
private readonly TenantConfigurationService _tenantConfigService;
private readonly ISessionRepository _sessionRepo;
public ProgressTenantStatusFunction(
ILogger<ProgressTenantStatusFunction> logger,
TenantConfigurationService tenantConfigService,
ISessionRepository sessionRepo)
{
_logger = logger;
_tenantConfigService = tenantConfigService;
_sessionRepo = sessionRepo;
}
/// <summary>GET /api/progress/tenant-status — the caller's own tenant (catalog: TenantScoping.Jwt).</summary>
[Function("ProgressGetTenantStatus")]
public async Task<HttpResponseData> GetTenantStatus(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "progress/tenant-status")] HttpRequestData req)
{
try
{
var tenantId = req.GetRequestContext().TenantId;
// Both reads are strict: a storage failure must surface as 500, never as "unused".
var configTask = _tenantConfigService.GetConfigurationIfExistsAsync(tenantId);
var hasSessionTask = _sessionRepo.HasAnySessionStrictAsync(tenantId);
await Task.WhenAll(configTask, hasSessionTask);
return await req.OkAsync(Evaluate(configTask.Result, hasSessionTask.Result, DateTime.UtcNow));
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "ProgressGetTenantStatus");
}
}
internal static ProgressTenantStatusResponse Evaluate(TenantConfiguration? config, bool hasAnySession, DateTime utcNow)
{
// No configuration row: not an onboarded organization, nothing to call unused.
if (config == null)
return new ProgressTenantStatusResponse { Success = true };
// A row from before OnboardedAt existed is old by definition.
var signedUpAt = config.OnboardedAt;
var settled = signedUpAt == null || utcNow - signedUpAt.Value >= UnusedAfter;
return new ProgressTenantStatusResponse
{
Success = true,
Unused = !hasAnySession && settled,
SignedUpAt = signedUpAt
};
}
}
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -65,6 +66,11 @@ public sealed class TimeAttributionInput
/// <item><b>Reboot spans from event gaps</b> around the <c>lastBootUtc</c> payload — the
/// <c>system_reboot_detected</c> event is detection-time-stamped by the next run and never used
/// as the reboot moment (audit Q7). Reboots are a cross-cutting annotation, not a segment.</item>
/// <item><b>The OOBE quality update takes its time from the phase segments</b> (v4, D-314):
/// <c>os_update</c> from the start of the update page that installed an update to the last
/// update evidence before the user is back, then — after a restart — <c>awaiting_sign_in</c>
/// until the first evidence of the user. Without update evidence (the page cancelled or found
/// nothing) the partition is the phase partition unchanged.</item>
/// </list>
/// </summary>
public static class TimeAttributionCalculator
@@ -74,7 +80,9 @@ public static class TimeAttributionCalculator
// pre-enrollment state on disk are now flagged and excluded from fleet segment stats.
// v3: SleepSpans — completed sleep/standby episodes (system_sleep_episode) as cross-cutting
// annotations mirroring RebootSpans; the maintenance sweep recomputes older rows.
public const int CurrentVersion = 3;
// v4: os_update + awaiting_sign_in — the OOBE quality update and the wait for the user after
// its restart, cut out of the phase segment they overlap (D-314), with OsUpdates naming the KBs.
public const int CurrentVersion = 4;
/// <summary>
/// Enrollment class for fleet aggregation — classes are NEVER mixed in one aggregate (a
@@ -142,6 +150,12 @@ public static class TimeAttributionCalculator
var anchors = BuildAnchors(events, ref flags);
var spans = BuildSegmentSpans(windows, anchors);
// D-310: the OOBE quality update and the wait for the user after its restart take their
// time from the phase segment they overlap — the ESP is over, the device is updating.
var rebootGaps = FindRebootGaps(events);
var osUpdates = BuildOsUpdates(events, windows, anchors, rebootGaps);
spans = ApplyOsUpdates(spans, windows, osUpdates);
var attributedSeconds = spans.Sum(s => s.Seconds);
var unattributed = wallClock - attributedSeconds;
@@ -169,7 +183,7 @@ public static class TimeAttributionCalculator
occupancy = ComputeOccupancySeconds(blockingAppSegments, espAppsSpans);
}
var rebootSpans = BuildRebootSpans(events, windows, spans);
var rebootSpans = BuildRebootSpans(rebootGaps, windows, spans);
var sleepSpans = BuildSleepSpans(events, windows, spans);
if (events.Any(e => e.EventType == Constants.EventTypes.AgentLateStart))
@@ -196,6 +210,16 @@ public static class TimeAttributionCalculator
RebootSpans = rebootSpans,
SleepSeconds = sleepSpans.Sum(s => s.Seconds),
SleepSpans = sleepSpans,
OsUpdates = osUpdates
.Select(u => new OsUpdateSpan
{
StartUtc = u.Start,
EndUtc = u.End,
Seconds = ClampToWindows(u.Start, u.End, windows)?.Seconds ?? 0,
Kbs = u.Kbs,
RebootCount = u.RebootCount,
})
.ToList(),
BlockingApps = blockingApps
.OrderByDescending(a => a.Seconds)
.ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase)
@@ -642,16 +666,17 @@ public static class TimeAttributionCalculator
// ── reboot spans ────────────────────────────────────────────────────────
/// <summary>
/// One span per distinct observed boot: the event-stream gap bracketing <c>lastBootUtc</c>
/// One gap per distinct observed boot: the event-stream gap bracketing <c>lastBootUtc</c>
/// (last event before the boot → first event at/after it). Fallback without a usable
/// payload: previous event → the detection event itself (which is genuinely post-boot —
/// usable as an observation bound, never as the boot moment). Spans are clipped to the
/// observation hull, so a WhiteGlove-pause reboot contributes nothing.
/// usable as an observation bound, never as the boot moment). Unclipped; the update interval
/// and <see cref="BuildRebootSpans"/> read the same gaps. A <c>system_clock_changed</c> row is
/// no bound (v4): the hardware clock sync at boot is logged a fraction of a second before
/// <c>lastBootUtc</c> and shrank the outage to seconds (field: 4–10 s for restarts of a minute).
/// </summary>
private static List<RebootSpan> BuildRebootSpans(
List<EnrollmentEvent> events, List<Window> windows, List<TimeAttributionSpan> spans)
private static List<(DateTime Start, DateTime End)> FindRebootGaps(List<EnrollmentEvent> events)
{
var result = new List<RebootSpan>();
var result = new List<(DateTime Start, DateTime End)>();
var seenBoots = new HashSet<DateTime>();
for (var i = 0; i < events.Count; i++)
@@ -677,6 +702,7 @@ public static class TimeAttributionCalculator
if (!seenBoots.Add(lastBoot.Value)) continue; // duplicate detection of the same boot
foreach (var e in events)
{
if (e.EventType == Constants.EventTypes.SystemClockChanged) continue;
if (e.Timestamp < lastBoot.Value)
{
if (!gapStart.HasValue || e.Timestamp > gapStart.Value) gapStart = e.Timestamp;
@@ -694,8 +720,23 @@ public static class TimeAttributionCalculator
}
if (!gapStart.HasValue || !gapEnd.HasValue || gapEnd.Value <= gapStart.Value) continue;
result.Add((gapStart.Value, gapEnd.Value));
}
var clamped = ClampToWindows(gapStart.Value, gapEnd.Value, windows);
return result;
}
/// <summary>
/// One span per reboot gap, clipped to the observation hull, so a WhiteGlove-pause reboot
/// contributes nothing.
/// </summary>
private static List<RebootSpan> BuildRebootSpans(
List<(DateTime Start, DateTime End)> gaps, List<Window> windows, List<TimeAttributionSpan> spans)
{
var result = new List<RebootSpan>();
foreach (var gap in gaps)
{
var clamped = ClampToWindows(gap.Start, gap.End, windows);
if (!clamped.HasValue) continue;
// In-window seconds — a reboot gap bracketing the WhiteGlove pause contributes
// only its in-window flanks, never the pause itself.
@@ -714,6 +755,296 @@ public static class TimeAttributionCalculator
return result;
}
// ── OOBE quality update (D-310) ─────────────────────────────────────────
/// <summary>CXID of the OOBE update page; <see cref="UpdateRestartPage"/> follows it and restarts for the update.</summary>
internal const string UpdatePage = "OobeNDUP";
internal const string UpdateRestartPage = "RebootNDUP";
// "Package_for_KB5129195~31bf…": the KB follows an underscore, so no \b before it.
private static readonly Regex KbPattern = new(
@"(?<![A-Za-z0-9])KB(\d{6,8})(?![0-9])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
private sealed class OsUpdate
{
public DateTime Start;
public DateTime End;
/// <summary>End of the wait for the user after the update's restart; null without a restart.</summary>
public DateTime? AwaitingSignInEnd;
public List<string> Kbs = new();
public int RebootCount;
}
private readonly struct PageRecord
{
public PageRecord(DateTime at, string cxhEvent, string? page, string? name)
{
At = at;
CxhEvent = cxhEvent;
Page = page;
Name = name;
}
public DateTime At { get; }
public string CxhEvent { get; }
public string? Page { get; }
public string? Name { get; }
public bool IsStartOf(string page) => CxhEvent == "page_started" && string.Equals(Page, page, StringComparison.OrdinalIgnoreCase);
public bool IsStopOf(string page) => CxhEvent == "page_stopped" && string.Equals(Page, page, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Page names that only an update in progress writes (field data 2026-10-02). Every visit
/// logs init names such as <c>initialize_NDUPInstallCanceledInOptOut</c> and
/// <c>initialize_NDUPDownloadInstallPreviousFailureCount</c>, a scan that found nothing
/// included, and <c>installSucceededNoReboot</c> follows the language check of visits whose
/// update came later — so "Download"/"Install" in a name proves nothing.
/// </summary>
private static readonly string[] UpdateActivityMarkers =
{
"commitExpeditionDownloadInstall", // the page commits the download and install
"downloadSucceeded",
"installSucceededRebootRequired",
"DownloadPhase", // the progress bars of the download …
"installPhase", // … and of the install
"downloadInstallFailureHelper", // a download or install failed
"rebootCountdown", // the page restarts for the update
};
/// <summary>
/// The update page's own record of a download or an install — evidence of an update next to
/// the servicing steps (an agent caps the page names per run, so this is the weaker of the two).
/// </summary>
internal static bool IsUpdateActivityName(string? name) =>
name != null &&
UpdateActivityMarkers.Any(marker => name.IndexOf(marker, StringComparison.OrdinalIgnoreCase) >= 0);
/// <summary>
/// At most one update per observation window. <b>Begin:</b> the start of the update page
/// (<see cref="UpdatePage"/>) whose visit holds update evidence — a servicing step or an
/// update-activity name before the visit ends (its stop, the <see cref="UpdateRestartPage"/>
/// start or the next visit). Only when no page start was observed in the window: the first
/// servicing step after the device-ESP exit. Never after the user is back. <b>Bound:</b> the first evidence of the
/// user (user ESP apps, desktop, Hello wizard, a Complete declaration), the end of
/// attribution (Failed) or the window end. <b>End:</b> the latest update evidence before the
/// bound — page record, servicing step, or the end of a restart that began in the interval.
/// With a restart in the interval the user is signed out: <c>awaiting_sign_in</c> runs from
/// the end to the bound.
/// </summary>
private static List<OsUpdate> BuildOsUpdates(
List<EnrollmentEvent> events, List<Window> windows, List<Anchor> anchors,
List<(DateTime Start, DateTime End)> rebootGaps)
{
var result = new List<OsUpdate>();
var pages = new List<PageRecord>();
var servicing = new List<(DateTime At, string? Package)>();
foreach (var evt in events)
{
if (evt.EventType == Constants.EventTypes.OobeUpdatePage && evt.Data != null)
{
pages.Add(new PageRecord(evt.Timestamp,
DataString(evt.Data, "cxhEvent") ?? string.Empty,
DataString(evt.Data, "page"),
DataString(evt.Data, "name")));
}
else if (evt.EventType == Constants.EventTypes.WindowsUpdateServicing)
{
servicing.Add((evt.Timestamp, evt.Data != null ? DataString(evt.Data, "package") : null));
}
}
if (servicing.Count == 0 && !pages.Any(p => IsUpdateActivityName(p.Name))) return result;
pages = pages.OrderBy(p => p.At).ToList();
servicing = servicing.OrderBy(c => c.At).ToList();
var evidence = servicing.Select(c => c.At)
.Concat(pages.Where(p => IsUpdateActivityName(p.Name)).Select(p => p.At))
.OrderBy(t => t)
.ToList();
// The user is back (or the enrollment declared itself complete): from here on the time
// is not the update's. A Failed declaration ends attribution altogether.
var userBack = anchors
.Where(a => a.Bucket == TimeAttributionSegments.UserEsp || a.Bucket == TimeAttributionSegments.DesktopHandoff)
.Select(a => a.Ts)
.Concat(events.Where(e => e.EventType == Constants.EventTypes.HelloWizardStarted).Select(e => e.Timestamp))
.OrderBy(t => t)
.ToList();
var attributionEnd = anchors.FirstOrDefault(a => a.Bucket == null)?.Ts;
var deviceEspExit = events.FirstOrDefault(e => e.EventType == Constants.EventTypes.EspExiting)?.Timestamp;
foreach (var window in windows)
{
var begin = FindUpdateBegin(window, pages, evidence, servicing, deviceEspExit, userBack);
if (!begin.HasValue) continue;
var bound = window.End;
if (attributionEnd.HasValue && attributionEnd.Value < bound) bound = attributionEnd.Value;
foreach (var t in userBack)
{
if (t <= begin.Value) continue;
if (t < bound) bound = t;
break;
}
if (bound <= begin.Value) continue;
var update = new OsUpdate { Start = begin.Value, End = begin.Value };
foreach (var page in pages)
{
if (page.At > update.End && page.At < bound) update.End = page.At;
}
foreach (var (at, package) in servicing)
{
if (at < begin.Value || at >= bound) continue;
if (at > update.End) update.End = at;
var kb = ExtractKb(package);
if (kb != null && !update.Kbs.Contains(kb)) update.Kbs.Add(kb);
}
foreach (var (gapStart, gapEnd) in rebootGaps)
{
if (gapStart < begin.Value || gapStart >= bound) continue;
update.RebootCount++;
var restartEnd = gapEnd < bound ? gapEnd : bound;
if (restartEnd > update.End) update.End = restartEnd;
}
// The update's restart signed the user out; until the user is back the time is
// waiting, not updating.
if (update.RebootCount > 0 && bound > update.End)
update.AwaitingSignInEnd = bound;
result.Add(update);
}
return result;
}
private static DateTime? FindUpdateBegin(
Window window, List<PageRecord> pages, List<DateTime> evidence,
List<(DateTime At, string? Package)> servicing, DateTime? deviceEspExit, List<DateTime> userBack)
{
// An update after the user is back runs beside the enrollment, not instead of it.
bool UserBackBy(DateTime t) => userBack.Any(u => u > window.Start && u <= t);
var pageStartSeen = false;
for (var i = 0; i < pages.Count; i++)
{
var start = pages[i];
if (!start.IsStartOf(UpdatePage) || start.At < window.Start || start.At >= window.End) continue;
pageStartSeen = true;
if (UserBackBy(start.At)) continue;
// The visit ends with its stop, the restart page or the next visit — whichever comes
// first; an update page that restarts the device writes no stop of its own.
var visitEnd = window.End;
for (var j = i + 1; j < pages.Count; j++)
{
var next = pages[j];
if (next.IsStopOf(UpdatePage) || next.IsStartOf(UpdateRestartPage) || next.IsStartOf(UpdatePage))
{
visitEnd = next.At;
break;
}
}
foreach (var t in userBack)
{
if (t <= start.At) continue;
if (t < visitEnd) visitEnd = t;
break;
}
if (evidence.Any(t => t >= start.At && t <= visitEnd)) return start.At;
}
// A page that was seen and did not update (it cancelled, or found nothing) is the answer;
// a servicing step beside it is some other update.
if (pageStartSeen) return null;
// Fallback: the page start was not observed (the agent started after it). The first
// servicing step after the device-ESP exit — without the download before it.
if (!deviceEspExit.HasValue) return null;
foreach (var (at, _) in servicing)
{
if (at < deviceEspExit.Value || at < window.Start || at >= window.End) continue;
return UserBackBy(at) ? null : at;
}
return null;
}
private static string? ExtractKb(string? package)
{
if (string.IsNullOrEmpty(package)) return null;
try
{
var match = KbPattern.Match(package);
return match.Success ? "KB" + match.Groups[1].Value : null;
}
catch (RegexMatchTimeoutException)
{
return null;
}
}
private static string? DataString(Dictionary<string, object> data, string key) =>
data.TryGetValue(key, out var value) ? value?.ToString() : null;
/// <summary>
/// Cuts the update and the wait for the user out of the phase spans and adds them as their
/// own spans, clipped to the windows — a hole inside the interval is filled, the WhiteGlove
/// pause never. Sessions without an update keep their phase partition unchanged.
/// </summary>
private static List<TimeAttributionSpan> ApplyOsUpdates(
List<TimeAttributionSpan> spans, List<Window> windows, List<OsUpdate> updates)
{
if (updates.Count == 0) return spans;
var overrides = new List<(DateTime Start, DateTime End, string Key)>();
foreach (var update in updates)
{
overrides.Add((update.Start, update.End, TimeAttributionSegments.OsUpdate));
if (update.AwaitingSignInEnd.HasValue)
overrides.Add((update.End, update.AwaitingSignInEnd.Value, TimeAttributionSegments.AwaitingSignIn));
}
var result = new List<TimeAttributionSpan>();
foreach (var span in spans)
{
var pieces = new List<(DateTime Start, DateTime End)> { (span.StartUtc, span.EndUtc) };
foreach (var cut in overrides)
{
var next = new List<(DateTime Start, DateTime End)>();
foreach (var (start, end) in pieces)
{
if (cut.End <= start || cut.Start >= end)
{
next.Add((start, end));
continue;
}
if (cut.Start > start) next.Add((start, cut.Start));
if (cut.End < end) next.Add((cut.End, end));
}
pieces = next;
}
foreach (var (start, end) in pieces)
AddSpan(result, span.SegmentKey, start, end);
}
foreach (var (start, end, key) in overrides)
{
foreach (var window in windows)
{
var clippedStart = start > window.Start ? start : window.Start;
var clippedEnd = end < window.End ? end : window.End;
AddSpan(result, key, clippedStart, clippedEnd);
}
}
return result.OrderBy(sp => sp.StartUtc).ToList();
}
// ── sleep spans ─────────────────────────────────────────────────────────
/// <summary>
@@ -256,6 +256,9 @@ public static class EndpointAccessPolicyCatalog
// ProgressPortalFunction; regression history c4dabeee).
new("GET", "progress/sessions/lookup", EndpointPolicy.AuthenticatedUserWithRole, TenantScoping.QueryParam),
new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser, TenantScoping.QueryParam),
// The caller's own (JWT) tenant only, and no session data: a "looks unused" flag plus the signup
// date, so the portal can tell a member without a role whom to ask for access.
new("GET", "progress/tenant-status", EndpointPolicy.AuthenticatedUser, TenantScoping.Jwt),
// AuthenticatedUserWithRole (not plain AuthenticatedUser): the activating user is still
// roleless during first-touch signup, so the tier must admit non-members — but the address is
// tenant-shared state feeding the welcome/farewell mails, so the function re-gates on the
@@ -36,16 +36,16 @@ public enum TableMemberState
/// <see cref="MemberRoleInfo.CanManageBootstrapTokens"/>, or to revoke access) by adding an
/// explicit table row for that user.
///
/// Only Admin and Operator are mappable from claims; Viewer and the platform-wide GlobalAdmin
/// role are intentionally never derived from claims.
/// The tenant roles Admin, Operator and Viewer are mappable from claims; the platform-wide
/// GlobalAdmin role is intentionally never derived from claims.
/// </summary>
public static class EntraAppRoleResolver
{
/// <summary>
/// Maps raw Entra app-role values to a <see cref="MemberRoleInfo"/>. Admin outranks Operator
/// when both are present. Returns null when no mappable role exists.
/// Maps raw Entra app-role values to a <see cref="MemberRoleInfo"/>. The highest role wins when
/// several are present: Admin, then Operator, then Viewer. Returns null when no mappable role exists.
/// A claim-derived Admin implicitly gets <see cref="MemberRoleInfo.CanManageBootstrapTokens"/>;
/// a claim Operator does not (granular bootstrap permission is a table-only override).
/// a claim Operator or Viewer does not (granular bootstrap permission is a table-only override).
/// </summary>
public static MemberRoleInfo? MapClaimRole(IEnumerable<string>? appRoles)
{
@@ -63,6 +63,9 @@ public static class EntraAppRoleResolver
if (roles.Any(r => string.Equals(r, Constants.TenantRoles.Operator, StringComparison.OrdinalIgnoreCase)))
return new MemberRoleInfo { Role = Constants.TenantRoles.Operator, CanManageBootstrapTokens = false };
if (roles.Any(r => string.Equals(r, Constants.TenantRoles.Viewer, StringComparison.OrdinalIgnoreCase)))
return new MemberRoleInfo { Role = Constants.TenantRoles.Viewer, CanManageBootstrapTokens = false };
return null;
}
@@ -405,7 +405,7 @@ namespace AutopilotMonitor.Functions.Services
/// contributes no per-app intervals); excluded and missing ones are counted, never
/// silently dropped (rule 7).
/// Rows are written even below the ≥20 UI gate — the UI needs the n (rule 4). Segment
/// stats always carry the five canonical segments + unattributed (a session without a
/// stats always carry the seven canonical segments + unattributed (a session without a
/// span of a segment contributes 0 — the honest "per enrollment of this class" answer).
/// Per-app rows gate at ≥5 sessions, order by median interval, cap 20.
/// </summary>
@@ -468,6 +468,8 @@ namespace AutopilotMonitor.Functions.Services
{
TimeAttributionSegments.DevicePrep,
TimeAttributionSegments.EspApps,
TimeAttributionSegments.OsUpdate,
TimeAttributionSegments.AwaitingSignIn,
TimeAttributionSegments.IdentityHello,
TimeAttributionSegments.UserEsp,
TimeAttributionSegments.DesktopHandoff,
@@ -1144,6 +1144,31 @@ namespace AutopilotMonitor.Functions.Services
return new RawPage<SessionSummary>(page.Sessions, page.HasMore ? page.NextCursor : null);
}
/// <summary>
/// Key-only existence probe: the tenant's SessionsIndex partition first, then the primary
/// table (the page read's pre-migration fallback). No catch — a failed read must never
/// answer "no sessions".
/// </summary>
public async Task<bool> HasAnySessionStrictAsync(string tenantId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
var filter = $"PartitionKey eq '{tenantId}'";
var keysOnly = new[] { "PartitionKey", "RowKey" };
foreach (var table in new[] { Constants.TableNames.SessionsIndex, Constants.TableNames.Sessions })
{
var rows = _tableServiceClient.GetTableClient(table)
.QueryAsync<TableEntity>(filter: filter, maxPerPage: 1, select: keysOnly)
.GetAsyncEnumerator();
await using (rows)
{
if (await rows.MoveNextAsync())
return true;
}
}
return false;
}
/// <summary>
/// Cross-tenant paged variant of <see cref="GetAllSessionsAsync"/>.
/// <paramref name="tenantIdFilter"/> optionally restricts to one tenant.
@@ -123,6 +123,7 @@ namespace AutopilotMonitor.Functions.Services
["SegmentsJson"] = JsonSerializer.Serialize(b.Segments),
["RebootSpansJson"] = JsonSerializer.Serialize(b.RebootSpans),
["SleepSpansJson"] = JsonSerializer.Serialize(b.SleepSpans),
["OsUpdatesJson"] = JsonSerializer.Serialize(b.OsUpdates),
["BlockingAppsJson"] = JsonSerializer.Serialize(b.BlockingApps),
["ComputedAt"] = DateTime.UtcNow,
};
@@ -151,6 +152,7 @@ namespace AutopilotMonitor.Functions.Services
Segments = DeserializeJsonColumn<TimeAttributionSpan>(entity.GetString("SegmentsJson")),
RebootSpans = DeserializeJsonColumn<RebootSpan>(entity.GetString("RebootSpansJson")),
SleepSpans = DeserializeJsonColumn<SleepSpan>(entity.GetString("SleepSpansJson")),
OsUpdates = DeserializeJsonColumn<OsUpdateSpan>(entity.GetString("OsUpdatesJson")),
BlockingApps = DeserializeJsonColumn<BlockingAppInterval>(entity.GetString("BlockingAppsJson")),
};
}
@@ -12,7 +12,7 @@ import { describe, it, expect } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadDocsCorpus, parseFrontmatter, docUrl, slugify, docSections } from '../docs-corpus.js';
import { loadDocsCorpus, parseFrontmatter, docUrl, slugify, docSections, summaryFolderSlugs } from '../docs-corpus.js';
async function withBundle<T>(
files: Record<string, string>,
@@ -60,9 +60,50 @@ describe('parseFrontmatter', () => {
});
});
const SUMMARY = [
'# Table of contents',
'',
'* [Introduction](README.md)',
'',
'## Concepts',
'',
'* [Roles & Permissions](concepts/roles-and-permissions.md)',
'',
'## Trust & Security',
'',
'* [Security & Privacy FAQ](trust/security-faq.md)',
'',
'## Troubleshooting & Support',
'',
'* [FAQ](troubleshooting/faq.md)',
'* [How to Purchase](troubleshooting-and-support/how-to-purchase/README.md)',
' * [Cleverbridge](troubleshooting-and-support/how-to-purchase/cleverbridge.md)',
].join('\r\n');
describe('summaryFolderSlugs', () => {
it('maps only the folders GitBook publishes under a different group slug', () => {
expect(summaryFolderSlugs(SUMMARY)).toEqual(
new Map([
['trust', 'trust-and-security'],
['troubleshooting', 'troubleshooting-and-support'],
]),
);
});
});
describe('docUrl', () => {
it('maps a page path to its published URL', () => {
expect(docUrl('trust/security-faq.md')).toBe('https://docs.autopilotmonitor.com/trust/security-faq');
expect(docUrl('concepts/roles-and-permissions.md')).toBe('https://docs.autopilotmonitor.com/concepts/roles-and-permissions');
});
it('publishes a page under its SUMMARY group slug, not its folder', () => {
// The folder URL of a page created after the group rename is a 404 (measured 2026-10-02).
const slugs = summaryFolderSlugs(SUMMARY);
expect(docUrl('troubleshooting/faq.md', slugs)).toBe('https://docs.autopilotmonitor.com/troubleshooting-and-support/faq');
expect(docUrl('trust/security-faq.md', slugs)).toBe('https://docs.autopilotmonitor.com/trust-and-security/security-faq');
expect(docUrl('troubleshooting-and-support/how-to-purchase/README.md', slugs)).toBe(
'https://docs.autopilotmonitor.com/troubleshooting-and-support/how-to-purchase',
);
});
it('collapses README.md to its folder', () => {
@@ -84,6 +125,22 @@ describe('slugify', () => {
describe('loadDocsCorpus', () => {
const page = (fm: string, body: string) => `---\n${fm}\n---\n\n${body}`;
it('cites the URL GitBook publishes, read from SUMMARY.md', async () => {
const chunks = await withBundle(
{
'SUMMARY.md': SUMMARY,
'troubleshooting/faq.md': page('type: Troubleshooting', ['# FAQ', '', '## Who is my admin?', '', 'Ask the team that runs Intune or device enrollment in your organization.'].join('\n')),
},
(root) => loadDocsCorpus(root),
);
expect(chunks.map((c) => c.metadata.url)).toContain(
'https://docs.autopilotmonitor.com/troubleshooting-and-support/faq#who-is-my-admin',
);
// The section filter keeps the folder taxonomy.
expect(chunks[0].metadata.section).toBe('troubleshooting');
});
it('splits on h2/h3 headings and carries a breadcrumb', async () => {
const chunks = await withBundle(
{
@@ -188,21 +188,49 @@ export function slugify(text: string): string {
.slice(0, 60);
}
/**
* Top-level folder → URL segment, read from SUMMARY.md. GitBook publishes a page under
* the slug of the `## Group` heading that lists it, not under its folder:
* `troubleshooting/faq.md` below "Troubleshooting & Support" is
* `/troubleshooting-and-support/faq`. The folder URL of a page that predates a group
* rename still redirects; for a newer page it is a 404. Only folders whose slug differs
* are returned.
*/
export function summaryFolderSlugs(summary: string): Map<string, string> {
const slugs = new Map<string, string>();
let group: string | undefined;
for (const line of normalizeNewlines(summary).split('\n')) {
const heading = /^##\s+(.+)$/.exec(line.trim());
if (heading) {
group = heading[1].toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
continue;
}
const link = /\]\(([^)#\s]+\.md)\)/.exec(line);
if (!group || !link || !link[1].includes('/')) continue;
const folder = link[1].split('/')[0];
if (folder !== group) slugs.set(folder, group);
}
return slugs;
}
/**
* Public GitBook URL for a bundle-relative path, or undefined when the file is
* not published as a page.
* not published as a page. `folderSlugs` (from {@link summaryFolderSlugs}) maps a
* top-level folder to the segment GitBook publishes it under.
*
* `index.md` is an OKF reserved file that the bundle deliberately keeps out of
* SUMMARY.md, so GitBook never renders it — emitting a link would hand callers a
* 404. Its content is still indexed (it is the documentation map); it just has no
* citable URL.
*/
export function docUrl(relPath: string): string | undefined {
export function docUrl(relPath: string, folderSlugs: ReadonlyMap<string, string> = new Map()): string | undefined {
const posix = relPath.split(sep).join('/');
if (posix === 'index.md') return undefined;
const withoutExt = posix.replace(/\.md$/, '');
const path = withoutExt === 'README' ? '' : withoutExt.replace(/\/README$/, '');
return path ? `${DOCS_BASE_URL}/${path}` : DOCS_BASE_URL;
const [folder, ...rest] = path.split('/');
const published = rest.length > 0 && folderSlugs.has(folder) ? [folderSlugs.get(folder), ...rest].join('/') : path;
return published ? `${DOCS_BASE_URL}/${published}` : DOCS_BASE_URL;
}
// ── Sectioning ───────────────────────────────────────────────
@@ -407,6 +435,8 @@ export async function loadDocsCorpus(docsRoot: string): Promise<SearchDocument[]
const files = (await walkMarkdown(docsRoot)).sort();
const docs: SearchDocument[] = [];
const usedIds = new Set<string>();
// No SUMMARY.md (a partial local bundle): folder URLs, as GitBook would serve an unrenamed group.
const folderSlugs = summaryFolderSlugs(await readFile(join(docsRoot, 'SUMMARY.md'), 'utf-8').catch(() => ''));
for (const relPath of files) {
let raw: string;
@@ -430,7 +460,7 @@ export async function loadDocsCorpus(docsRoot: string): Promise<SearchDocument[]
// Top-level directory doubles as the bundle's section taxonomy
// (getting-started, concepts, portal-guide, …). Root files are 'general'.
const section = posixPath.includes('/') ? posixPath.split('/')[0] : 'general';
const url = docUrl(relPath);
const url = docUrl(relPath, folderSlugs);
// Drop the h1 line itself — it is carried in every chunk's header instead.
const withoutH1 = body.replace(/^#\s+.+$/m, '');
@@ -3054,6 +3054,18 @@ export interface OpsEventListResponse {
nextLink?: string;
}
/** One OOBE quality update behind an OsUpdate span (D-310) — the interval and what the evidence names. Annotation like RebootSpan; the partition itself is in Segments. */
export interface OsUpdateSpan {
startUtc: string;
endUtc: string;
/** In-window seconds of the interval. */
seconds: number;
/** KB numbers of the packages Windows serviced in the interval ("KB5129195"), in order of first appearance; empty when no package named one. */
kbs: string[];
/** Restarts that began inside the interval. */
rebootCount: number;
}
/** Body of PATCH global/config: { "fields": { <fieldName>: <value>, ... } } with at least one field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take the field's own JSON type. */
export interface PatchAdminConfigurationRequest {
fields?: Record<string, unknown>;
@@ -3231,6 +3243,15 @@ export interface ProgressLookupSessionResponse {
session?: SessionSummary;
}
/** Response of GET /api/progress/tenant-status — whether the caller's own organization looks unused, so the Progress Portal can tell a member without a role whom to ask for access. */
export interface ProgressTenantStatusResponse {
success: boolean;
/** True when the organization holds no session and signed up more than 14 days ago. */
unused: boolean;
/** When the organization signed up; null (key omitted on the wire) when unknown. */
signedUpAt?: string;
}
/** Error body of POST /api/global/raw/logs when the telemetry store rejected or failed the query (400 for a caller-side KQL error, 502 for store/grant failures): the envelope prefix plus the store's own error code, HTTP status and — capped — its full response, so nothing the CLI would print is lost. hint tells the caller how to fix the query. */
export interface QueryBackendLogsErrorResponse {
error: string;
@@ -4334,6 +4355,8 @@ export interface SessionTimeBreakdown {
/** Total observed in-window sleep seconds. Cross-cutting annotation like RebootSeconds — the wall clock keeps the pause; this discloses it. */
sleepSeconds: number;
sleepSpans: SleepSpan[];
/** The OOBE quality updates behind the OsUpdate spans (at most one per observation window). */
osUpdates: OsUpdateSpan[];
/** Install intervals of ESP-blocking apps (positive-evidence join against the latest esp_config_detected lists), top 20 by duration. BlockingAppCount carries the uncapped count of matched blocking apps (including those without a measurable interval, e.g. unobserved start). */
blockingApps: BlockingAppInterval[];
/** Uncapped count of apps matched against the blocking set (see BlockingApps). */
@@ -4904,7 +4927,7 @@ export interface TenantConfiguration {
unrestrictedModeEnabled: boolean;
/** When effective, agent guardrails are relaxed: all registry paths, WMI queries, and commands are allowed via GatherRules. File paths and diagnostics paths are allowed except C:\Users. Default: false. Can only be toggled by tenant admins when UnrestrictedModeEnabled is true. EFFECTIVE only while the tenant's edition is Pro (read-time re-gate, fail-closed on trial expiry/downgrade) — see TenantEntitlementService.IsUnrestrictedModeActive. */
unrestrictedMode: boolean;
/** When enabled, tenant member roles (Admin / Operator) may also be granted via Entra ID app-role assignments on the application's Enterprise App (the "roles" claim in the user's token), in addition to the TenantAdmins table. Resolution is table-first: an explicit TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator CanManageBootstrapTokens). Only Admin and Operator are claim-mappable; the platform-wide GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in. Backend-only setting: not delivered to the agent (no ConfigVersion impact). */
/** When enabled, tenant member roles (Admin / Operator / Viewer) may also be granted via Entra ID app-role assignments on the application's Enterprise App (the "roles" claim in the user's token), in addition to the TenantAdmins table. Resolution is table-first: an explicit TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator CanManageBootstrapTokens). The platform-wide GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in. Backend-only setting: not delivered to the agent (no ConfigVersion impact). */
entraAppRolesEnabled: boolean;
/** JSON-serialized list of tenant-specific additional log paths/wildcards to include in the diagnostics ZIP package (additive to global paths). Each entry: { "path": "...", "description": "...", "isBuiltIn": false } */
diagnosticsLogPathsJson: string;
@@ -2057,9 +2057,11 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
title: 'Enrollment Time Attribution',
description:
'Answer "where did the enrollment time go?". Two modes: pass sessionId for one terminal session\'s ' +
'time breakdown — wall-clock partition into device_prep / esp_apps / identity_hello / user_esp / ' +
'desktop_handoff spans plus an EXPLICIT unattributed remainder (sums are exact against the session\'s ' +
'authoritative duration; the WhiteGlove pause is excluded by design), the ESP-blocking app install ' +
'time breakdown — wall-clock partition into device_prep / esp_apps / os_update / awaiting_sign_in / ' +
'identity_hello / user_esp / desktop_handoff spans plus an EXPLICIT unattributed remainder (sums are exact ' +
'against the session\'s authoritative duration; the WhiteGlove pause is excluded by design; os_update = the ' +
'OOBE quality update, its KBs and restarts in osUpdates; awaiting_sign_in = after that update\'s restart ' +
'until the user is back), the ESP-blocking app install ' +
'intervals with critical-path occupancy (an app\'s seconds = its ACTIVE install time summed across all ' +
'IME passes/attempts — deliberately broader than get_app_install_metrics\' final-attempt duration, ' +
'because a failed attempt and an evaluation pass still occupied the critical path), reboot outage ' +
@@ -54,6 +54,13 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<RawPage<SessionSummary>> GetSessionsPageAsync(
string tenantId, int? days, int pageSize, string? continuation);
/// <summary>
/// True when <paramref name="tenantId"/> holds at least one session (SessionsIndex, with the
/// same primary-table fallback as <see cref="GetSessionsPageAsync"/>). Strict: storage failures
/// PROPAGATE — the page read answers an empty list on failure, which would read as "no sessions".
/// </summary>
Task<bool> HasAnySessionStrictAsync(string tenantId);
/// <summary>
/// Cross-tenant variant of <see cref="GetSessionsPageAsync"/> (Global Admin).
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant.
@@ -662,12 +662,12 @@ namespace AutopilotMonitor.Shared.Models
// ===== ENTRA APP ROLES (RBAC) =====
/// <summary>
/// When enabled, tenant member roles (Admin / Operator) may also be granted via Entra ID
/// When enabled, tenant member roles (Admin / Operator / Viewer) may also be granted via Entra ID
/// app-role assignments on the application's Enterprise App (the "roles" claim in the user's
/// token), in addition to the TenantAdmins table. Resolution is table-first: an explicit
/// TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator
/// CanManageBootstrapTokens). Only Admin and Operator are claim-mappable; the platform-wide
/// GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in.
/// CanManageBootstrapTokens). The platform-wide GlobalAdmin role is never derived from
/// claims. Off by default — per-tenant opt-in.
/// Backend-only setting: not delivered to the agent (no ConfigVersion impact).
/// </summary>
public bool EntraAppRolesEnabled { get; set; } = false;
@@ -17,6 +17,21 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>Device-scope ESP apps phase (EnrollmentPhase.AppsDevice spans).</summary>
public const string EspApps = "esp_apps";
/// <summary>
/// The OOBE quality update (D-310): from the start of the update page that installed an
/// update to the last update evidence before the user is back — download, install, the
/// restarts and the servicing after them. Takes the time from the phase segment it
/// overlaps; one <see cref="OsUpdateSpan"/> per interval names the packages.
/// </summary>
public const string OsUpdate = "os_update";
/// <summary>
/// After an OOBE update that restarted the device: from the update's end to the first
/// evidence of the user (desktop, Hello wizard, user ESP apps) — the restart signed the
/// user out, so this is the time until someone signs in again.
/// </summary>
public const string AwaitingSignIn = "awaiting_sign_in";
/// <summary>Identity / sign-in / Windows Hello (AccountSetup + FinalizingSetup spans).</summary>
public const string IdentityHello = "identity_hello";
@@ -168,6 +183,26 @@ namespace AutopilotMonitor.Shared.Models
public string Kind { get; set; } = string.Empty;
}
/// <summary>
/// One OOBE quality update behind an <see cref="TimeAttributionSegments.OsUpdate"/> span
/// (D-310) — the interval and what the evidence names. Annotation like
/// <see cref="RebootSpan"/>; the partition itself is in <see cref="SessionTimeBreakdown.Segments"/>.
/// </summary>
public class OsUpdateSpan
{
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
/// <summary>In-window seconds of the interval.</summary>
public int Seconds { get; set; }
/// <summary>KB numbers of the packages Windows serviced in the interval ("KB5129195"), in order of first appearance; empty when no package named one.</summary>
public List<string> Kbs { get; set; } = new List<string>();
/// <summary>Restarts that began inside the interval.</summary>
public int RebootCount { get; set; }
}
/// <summary>
/// Per-session enrollment time attribution (F1, insights spec) — an exact partition of the
/// session's authoritative wall clock into named segments plus an explicit unattributed
@@ -213,6 +248,9 @@ namespace AutopilotMonitor.Shared.Models
public List<SleepSpan> SleepSpans { get; set; } = new List<SleepSpan>();
/// <summary>The OOBE quality updates behind the <see cref="TimeAttributionSegments.OsUpdate"/> spans (at most one per observation window).</summary>
public List<OsUpdateSpan> OsUpdates { get; set; } = new List<OsUpdateSpan>();
/// <summary>
/// Install intervals of ESP-blocking apps (positive-evidence join against the latest
/// <c>esp_config_detected</c> lists), top 20 by duration. <see cref="BlockingAppCount"/>
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
@@ -30,4 +31,20 @@ namespace AutopilotMonitor.Shared.Models
public int Count { get; set; }
public IReadOnlyList<EnrollmentEvent> Events { get; set; } = default!;
}
/// <summary>
/// Response of GET /api/progress/tenant-status — whether the caller's own organization looks
/// unused, so the Progress Portal can tell a member without a role whom to ask for access.
/// </summary>
// Declaration order == wire order.
public class ProgressTenantStatusResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>True when the organization holds no session and signed up more than 14 days ago.</summary>
public bool Unused { get; set; }
/// <summary>When the organization signed up; null (key omitted on the wire) when unknown.</summary>
public DateTime? SignedUpAt { get; set; }
}
}
@@ -5,6 +5,8 @@ import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchOk, jsonBody } from "@/lib/apiClient";
import { landingTarget } from "@/lib/landingTarget";
import { consumePostLoginReturnUrl } from "@/lib/postLoginReturn";
import { DOCS_URL } from "@/utils/config";
import { BrandMark } from "../../components/BrandMark";
import type { SaveNotificationEmailRequest } from "@/utils/wire-types.generated";
@@ -62,6 +64,7 @@ export default function ActivationPage() {
// are asked for an address; an already-activated tenant opening /activation later is passed
// straight through, exactly as before.
const arrivedPendingRef = useRef<boolean | null>(null);
const leftRef = useRef(false);
// Redirect out of the activation page — but not while the address question is still open.
useEffect(() => {
@@ -85,7 +88,11 @@ export default function ActivationPage() {
// which is why almost no tenant ever got a welcome mail. Hold until the user has answered.
if (arrivedPendingRef.current && !addressDecided) return;
router.push(user.isTenantAdmin || user.isGlobalAdmin ? "/dashboard" : "/progress");
// Leave once: the deep link is consumed here, so a re-run before unmount must not route again
// without it. The landing gate kept it during activation for exactly this hand-off.
if (leftRef.current) return;
leftRef.current = true;
router.push(landingTarget(user, consumePostLoginReturnUrl()));
}, [demo, isAuthenticated, isLoading, user, isActivationPending, addressDecided, router]);
// Poll while activation is pending: the auto-approve worker activates the tenant
@@ -4,8 +4,10 @@ import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { AddMemberForm } from "@/components/members/AddMemberForm";
import { MemberList } from "@/components/members/MemberList";
import type { AddTenantAdminRequest, TenantAdminRow, UpdateMemberPermissionsRequest } from "@/utils/wire-types.generated";
import { isApplicationKey, looksLikeGuid, principalLabel, type MemberKind } from "@/utils/principalKeys";
import { looksLikeGuid, type MemberKind } from "@/utils/principalKeys";
// Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows).
type TenantAdmin = TenantAdminRow;
@@ -35,16 +37,11 @@ export function TenantAdminSection({
const [addingAdmin, setAddingAdmin] = useState(false);
const [removingAdmin, setRemovingAdmin] = useState<string | null>(null);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
const [currentAdminPage, setCurrentAdminPage] = useState(0);
const [adminSearchQuery, setAdminSearchQuery] = useState("");
const adminsPerPage = 5;
// Fetch tenant admins when tenantId changes
const fetchTenantAdmins = async (tid: string) => {
try {
setLoadingAdmins(true);
setCurrentAdminPage(0); // Reset to first page when loading new tenant
const data = await fetchJson<TenantAdmin[]>(api.tenants.admins(tid), getAccessToken);
setTenantAdmins(data);
} catch (err) {
@@ -98,11 +95,7 @@ export function TenantAdminSection({
};
const handleRemoveTenantAdmin = async (adminUpn: string) => {
if (!canMutate) return; // read-only Global Reader
if (!confirm(`Are you sure you want to remove ${adminUpn} as an admin?`)) {
return;
}
if (!canMutate) return; // read-only Global Reader (the row asks for confirmation first)
try {
setRemovingAdmin(adminUpn);
setError(null);
@@ -176,259 +169,43 @@ export function TenantAdminSection({
}
};
// Admin Pagination with Search
const filteredAdmins = tenantAdmins.filter(admin =>
principalLabel(admin.upn).toLowerCase().includes(adminSearchQuery.toLowerCase())
);
const totalAdminPages = Math.ceil(filteredAdmins.length / adminsPerPage);
const startAdminIndex = currentAdminPage * adminsPerPage;
const endAdminIndex = startAdminIndex + adminsPerPage;
const paginatedAdmins = filteredAdmins.slice(startAdminIndex, endAdminIndex);
return (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<svg className="w-5 h-5 text-purple-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<div className="flex-1">
<p className="font-semibold text-purple-900 mb-2">Admin Users Management</p>
<div className="flex-1 min-w-0 space-y-4">
<p className="font-semibold text-purple-900">
Members
{loadingAdmins && <span className="ml-2 text-sm font-normal text-purple-700">(Loading...)</span>}
</p>
{/* Current Admins List */}
<div className="mb-3">
<p className="text-sm text-purple-800 font-medium mb-2">
Current Admins ({tenantAdmins.length}):
</p>
<AddMemberForm
value={newAdminEmail}
onValueChange={setNewAdminEmail}
role={newMemberRole}
onRoleChange={setNewMemberRole}
kind={newMemberKind}
onKindChange={setNewMemberKind}
adding={addingAdmin}
onAdd={handleAddTenantAdmin}
disabled={!canMutate}
stacked
/>
{/* Search Field */}
<div className="mb-3">
<div className="relative">
<input
type="text"
name="admin-search-modal"
value={adminSearchQuery}
onChange={(e) => {
setAdminSearchQuery(e.target.value);
setCurrentAdminPage(0);
}}
placeholder="Search by email..."
autoComplete="off"
className="w-full px-3 py-2 pl-9 pr-9 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
/>
<svg className="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{adminSearchQuery && (
<button
onClick={() => {
setAdminSearchQuery("");
setCurrentAdminPage(0);
}}
className="absolute right-2.5 top-2.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear search"
>
<svg className="w-4 h-4" 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>
{loadingAdmins ? (
<div className="text-sm text-purple-700">Loading admins...</div>
) : tenantAdmins.length === 0 ? (
<div className="text-sm text-purple-700 italic">No admins configured yet</div>
) : filteredAdmins.length === 0 ? (
<div className="text-sm text-purple-700 italic p-3 text-center bg-purple-50 rounded-lg">
No admins match your search
</div>
) : (
<>
<div className="space-y-2">
{paginatedAdmins.map((admin) => {
const isApplication = isApplicationKey(admin.upn);
const effectiveRole = isApplication ? "Viewer" : (admin.role ?? "Admin");
return (
<div
key={admin.upn}
className={`p-2 bg-white border rounded text-sm ${
admin.isEnabled ? 'border-purple-200' : 'border-gray-300 bg-gray-50'
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<div className="font-medium text-gray-900 truncate">{principalLabel(admin.upn)}</div>
{isApplication && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700" title="Service principal — automation with its own token, or an app whose users connect through it; read-only">
App
</span>
)}
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
effectiveRole === "Admin"
? "bg-purple-100 text-purple-800"
: effectiveRole === "Operator"
? "bg-blue-100 text-blue-800"
: "bg-gray-100 text-gray-700"
}`}>
{effectiveRole}
</span>
{!admin.isEnabled && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-700">
Disabled
</span>
)}
</div>
<div className="text-xs text-gray-500">
Added {new Date(admin.addedDate).toLocaleDateString()} by {admin.addedBy}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={effectiveRole}
onChange={(e) => handleUpdatePermissions(admin.upn, e.target.value, admin.canManageBootstrapTokens)}
disabled={!canMutate || isApplication || togglingAdmin === admin.upn}
title={isApplication ? "A service principal is always read-only (Viewer)" : undefined}
className="px-2 py-1 text-xs border border-gray-300 rounded bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-purple-500 disabled:opacity-50"
>
<option value="Admin">Admin</option>
<option value="Operator">Operator</option>
<option value="Viewer">Viewer</option>
</select>
<button
onClick={() => handleToggleTenantAdmin(admin.upn, admin.isEnabled)}
disabled={!canMutate || togglingAdmin === admin.upn}
className={`px-2 py-1 text-xs rounded hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed ${
admin.isEnabled
? 'bg-yellow-600 text-white hover:bg-yellow-700'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{togglingAdmin === admin.upn
? "..."
: admin.isEnabled ? "Disable" : "Enable"}
</button>
<button
onClick={() => handleRemoveTenantAdmin(admin.upn)}
disabled={!canMutate || removingAdmin === admin.upn}
className="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{removingAdmin === admin.upn ? "..." : "Remove"}
</button>
</div>
</div>
{/* Bootstrap token permission toggle for Operators */}
{effectiveRole === "Operator" && (
<div className="mt-2 pt-2 border-t border-gray-200">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={admin.canManageBootstrapTokens}
onChange={(e) => handleUpdatePermissions(admin.upn, "Operator", e.target.checked)}
disabled={!canMutate || togglingAdmin === admin.upn}
className="h-4 w-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500 disabled:opacity-50"
/>
<span className="text-sm text-gray-700">Can manage bootstrap tokens</span>
</label>
</div>
)}
</div>
);
})}
</div>
{/* Pagination */}
{totalAdminPages > 1 && (
<div className="flex items-center justify-between pt-3 mt-3 border-t border-purple-200">
<button
onClick={() => setCurrentAdminPage(p => Math.max(0, p - 1))}
disabled={currentAdminPage === 0}
className="px-3 py-1 text-xs font-medium text-purple-700 bg-white border border-purple-300 rounded hover:bg-purple-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-xs text-purple-700">
Page {currentAdminPage + 1} of {totalAdminPages} ({filteredAdmins.length} admin{filteredAdmins.length !== 1 ? 's' : ''})
</span>
<button
onClick={() => setCurrentAdminPage(p => Math.min(totalAdminPages - 1, p + 1))}
disabled={currentAdminPage >= totalAdminPages - 1}
className="px-3 py-1 text-xs font-medium text-purple-700 bg-white border border-purple-300 rounded hover:bg-purple-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
{/* Add New Admin */}
<div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<p className="text-sm text-purple-800 font-medium">Add New Admin:</p>
<div className="inline-flex rounded border border-purple-300 overflow-hidden text-xs" role="group" aria-label="Member type">
{([["user", "User"], ["application", "Service principal"]] as const).map(([kind, label]) => (
<button
key={kind}
type="button"
onClick={() => { setNewMemberKind(kind); setNewAdminEmail(""); }}
className={`px-2 py-1 ${newMemberKind === kind ? "bg-purple-600 text-white" : "bg-white text-purple-700 hover:bg-purple-50"}`}
>
{label}
</button>
))}
</div>
</div>
{addingApplication && (
<p className="text-xs text-purple-700 mb-2">
An application in this tenant, always read-only (Viewer). Automation calling with an app-only token
(for example through a federated credential) needs the{" "}
<span className="font-mono">access_as_application</span> permission. An app that signs users in and
sends their tokens needs the delegated{" "}
<span className="font-mono">access_as_user</span> permission, and its users can only read. Either is
granted by admin consent in the tenant.
</p>
)}
<div className="flex space-x-2">
<input
type={addingApplication ? "text" : "email"}
name="modal-new-admin-email"
id="modal-add-admin-email-input"
value={newAdminEmail}
onChange={(e) => setNewAdminEmail(e.target.value)}
placeholder={addingApplication ? "Application (client) ID, e.g. 00000000-0000-0000-0000-000000000000" : "admin@tenant.com"}
autoComplete="off"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTenantAdmin();
}
}}
/>
<select
value={addingApplication ? "Viewer" : newMemberRole}
onChange={(e) => setNewMemberRole(e.target.value)}
disabled={addingApplication}
title={addingApplication ? "A service principal is always read-only (Viewer)" : undefined}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors disabled:opacity-50"
>
<option value="Admin">Admin</option>
<option value="Operator">Operator</option>
<option value="Viewer">Viewer</option>
</select>
<button
onClick={() => handleAddTenantAdmin()}
disabled={!canMutate || addingAdmin || !newMemberInputValid}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
{addingAdmin ? "Adding..." : "Add"}
</button>
</div>
</div>
{/* key: a tenant switch starts on page 1 with no filter, search or open row. */}
<MemberList
key={tenantId}
members={tenantAdmins}
loading={loadingAdmins}
canMutate={canMutate}
removingUpn={removingAdmin}
updatingUpn={togglingAdmin}
onRemove={handleRemoveTenantAdmin}
onToggleEnabled={handleToggleTenantAdmin}
onUpdatePermissions={handleUpdatePermissions}
/>
</div>
</div>
</div>
@@ -1255,7 +1255,7 @@ function TenantManagementSectionInner({
<span className="text-sm font-medium text-gray-700">Enable Entra App Roles</span>
</label>
<p className="text-xs text-gray-400 mt-1 ml-6">
Allow Admin/Operator roles to be granted via Entra app-role assignments on the Enterprise App (the token&apos;s roles claim), in addition to the member table. The member table always wins.
Allow Admin/Operator/Viewer roles to be granted via Entra app-role assignments on the Enterprise App (the token&apos;s roles claim), in addition to the member table. The member table always wins.
</p>
</div>
@@ -4,6 +4,8 @@
* and are marked noindex (events.guard.test.ts pins all four).
*/
import { SUPPORT_EMAIL } from "@/lib/supportContact";
export interface EventArt {
src: string;
width: number;
@@ -21,7 +23,7 @@ export interface FollowupEvent {
art?: { hero: EventArt; starPointer: EventArt };
}
export const WALKTHROUGH_EMAIL = "support@autopilotmonitor.com";
export const WALKTHROUGH_EMAIL = SUPPORT_EMAIL;
/** Robots metadata of every event page. Not a robots.txt disallow: crawlers would then never see it. */
export const EVENT_PAGE_ROBOTS = {
@@ -53,6 +53,8 @@ const MIN_SESSIONS_FOR_PANEL = 20;
const SEGMENT_META: { key: string; label: string; color: string }[] = [
{ key: "device_prep", label: "Device preparation", color: "bg-slate-400" },
{ key: "esp_apps", label: "Apps (ESP)", color: "bg-blue-500" },
{ key: "os_update", label: "Windows Update", color: "bg-cyan-500" },
{ key: "awaiting_sign_in", label: "Waiting for sign-in", color: "bg-sky-200" },
{ key: "identity_hello", label: "Identity & Hello", color: "bg-violet-500" },
{ key: "user_esp", label: "User ESP", color: "bg-indigo-400" },
{ key: "desktop_handoff", label: "Desktop handoff", color: "bg-emerald-500" },
@@ -0,0 +1,42 @@
import { DOCS_PATHS } from "@/lib/docsPaths";
import { SUPPORT_EMAIL } from "@/lib/supportContact";
import { DOCS_URL } from "@/utils/config";
import type { ProgressAccessHint as Hint } from "../hooks/progressAccessHint";
export const ACCESS_HINT_DOCS_URL = `${DOCS_URL}${DOCS_PATHS.progressPortalOnly}`;
const LINK_CLASS = "text-green-700 underline hover:text-green-800";
/** One muted line below the search telling a member without a role whom to ask for the full portal. */
export function ProgressAccessHint({ hint }: { hint: Hint }) {
const learnMore = (
<a href={ACCESS_HINT_DOCS_URL} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
Learn more
</a>
);
if (hint.kind === "member") {
return (
<p className="mt-10 text-center text-sm text-gray-500">
{"Looking for the full portal? Your organization already uses Autopilot Monitor. Ask your Autopilot Monitor admin for a role."}
{" "}
{learnMore}
</p>
);
}
const signedUp = hint.signedUpOn ? ` on ${hint.signedUpOn}` : "";
return (
<p className="mt-10 text-center text-sm text-gray-500">
{`Looking for the full portal? Your organization signed up for Autopilot Monitor${signedUp}, but no device has been monitored recently. Ask your Autopilot Monitor admin for a role, or write to`}
{" "}
<a href={`mailto:${SUPPORT_EMAIL}`} className={LINK_CLASS}>
{SUPPORT_EMAIL}
</a>
{" "}
{"if no one looks after it anymore."}
{" "}
{learnMore}
</p>
);
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ACCESS_HINT_DOCS_URL, ProgressAccessHint } from "../ProgressAccessHint";
import { SUPPORT_EMAIL } from "@/lib/supportContact";
import { DOCS_URL } from "@/utils/config";
const render = (hint: Parameters<typeof ProgressAccessHint>[0]["hint"]) =>
renderToStaticMarkup(createElement(ProgressAccessHint, { hint }));
describe("ProgressAccessHint", () => {
it("points a member to their admins and names no contact address", () => {
const html = render({ kind: "member" });
expect(html).toContain(
"Looking for the full portal? Your organization already uses Autopilot Monitor. Ask your Autopilot Monitor admin for a role.",
);
expect(html).not.toContain("mailto:");
});
it("offers the support mailbox for an unused organization, with spaces around the link", () => {
const html = render({ kind: "unused", signedUpOn: "12 March 2026" });
expect(html).toContain("signed up for Autopilot Monitor on 12 March 2026, but no device has been monitored recently.");
expect(html).toContain(`href="mailto:${SUPPORT_EMAIL}"`);
expect(html).toMatch(/or write to <a [^>]+>support@autopilotmonitor\.com<\/a> if no one looks after it anymore\./);
});
it("drops the date clause cleanly when the date is unknown", () => {
const html = render({ kind: "unused", signedUpOn: null });
expect(html).toContain("signed up for Autopilot Monitor, but no device");
});
it("links both variants to the troubleshooting article, after a space", () => {
// GitBook publishes troubleshooting/ under its group slug; the folder URL of a new page is a 404.
expect(ACCESS_HINT_DOCS_URL).toBe(`${DOCS_URL}/troubleshooting-and-support/progress-portal-only`);
// Plain substring match: a URL interpolated into a RegExp needs every dot escaped.
const link = `. <a href="${ACCESS_HINT_DOCS_URL}" target="_blank" rel="noopener noreferrer"`;
for (const hint of [{ kind: "member" as const }, { kind: "unused" as const, signedUpOn: null }]) {
const html = render(hint);
const start = html.indexOf(link);
expect(start).toBeGreaterThanOrEqual(0);
expect(html.slice(start, html.indexOf("</a>", start))).toMatch(/>Learn more$/);
}
});
});
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { formatSignupDate, progressAccessHint } from "../progressAccessHint";
describe("progressAccessHint", () => {
it("keeps the member hint while the status is pending or failed", () => {
expect(progressAccessHint(null)).toEqual({ kind: "member" });
});
it("keeps the member hint for an organization in use", () => {
expect(progressAccessHint({ success: true, unused: false, signedUpAt: "2026-03-12T08:30:00Z" })).toEqual({
kind: "member",
});
});
it("names the signup date of an unused organization", () => {
expect(progressAccessHint({ success: true, unused: true, signedUpAt: "2026-03-12T08:30:00Z" })).toEqual({
kind: "unused",
signedUpOn: "12 March 2026",
});
});
it("leaves the date out when the backend does not know it", () => {
expect(progressAccessHint({ success: true, unused: true })).toEqual({ kind: "unused", signedUpOn: null });
});
});
describe("formatSignupDate", () => {
it("reads the date in UTC, so a late-evening signup keeps its day", () => {
expect(formatSignupDate("2026-03-12T23:30:00Z")).toBe("12 March 2026");
});
it("returns null for an unparsable value", () => {
expect(formatSignupDate("not a date")).toBeNull();
});
});
@@ -0,0 +1,23 @@
import type { ProgressTenantStatusResponse } from "@/utils/wire-types.generated";
/** The note below the Progress Portal search for a member without a role. */
export type ProgressAccessHint =
| { kind: "member" }
| { kind: "unused"; signedUpOn: string | null };
/**
* A member without a role always gets the hint; the tenant status only sharpens it. A pending or
* failed status read (null) keeps the plain member hint, which needs no data.
*/
export function progressAccessHint(status: ProgressTenantStatusResponse | null): ProgressAccessHint {
if (!status?.unused) return { kind: "member" };
return { kind: "unused", signedUpOn: formatSignupDate(status.signedUpAt) };
}
/** "12 March 2026" (the docs' date style); null for a missing or unparsable date. */
export function formatSignupDate(iso: string | undefined): string | null {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" });
}
@@ -0,0 +1,34 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { fetchJson, type GetAccessToken } from "@/lib/apiClient";
import type { ProgressTenantStatusResponse } from "@/utils/wire-types.generated";
/**
* One read of the caller's tenant status for the access hint, only for members without a role
* (`enabled`). Any failure leaves null: the hint then stays the plain member note, so nothing here
* is worth a notification.
*/
export function useProgressTenantStatus(
enabled: boolean,
getAccessToken: GetAccessToken,
): ProgressTenantStatusResponse | null {
const [status, setStatus] = useState<ProgressTenantStatusResponse | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
const run = async () => {
const data = await fetchJson<ProgressTenantStatusResponse>(api.progress.tenantStatus(), getAccessToken)
.catch(() => null);
if (!cancelled) setStatus(data);
};
void run();
return () => {
cancelled = true;
};
}, [enabled, getAccessToken]);
return status;
}
@@ -10,8 +10,12 @@ import { useProgressEvents } from "./hooks/useProgressEvents";
import { useProgressSignalR } from "./hooks/useProgressSignalR";
import { useProgressDerivedData } from "./hooks/useProgressDerivedData";
import { DeviceStatusChips } from "./components/DeviceStatusChips";
import { ProgressAccessHint } from "./components/ProgressAccessHint";
import type { PresentationKind } from "./hooks/progressLayout";
import { progressAccessHint } from "./hooks/progressAccessHint";
import { useProgressTenantStatus } from "./hooks/useProgressTenantStatus";
import { useNotifications } from "../../contexts/NotificationContext";
import { hasTenantReadScope } from "../../lib/tenantScope";
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
@@ -49,10 +53,14 @@ function stepState(index: number, activeStepIndex: number, kind: PresentationKin
export default function ProgressPortalPage() {
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const { user, getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const signalR = useSignalR();
// Members without a role only ever see this page; tell them the full portal exists.
const showAccessHint = !!user && !hasTenantReadScope(user);
const tenantStatus = useProgressTenantStatus(showAccessHint, getAccessToken);
const search = useProgressSearch({
tenantId,
getAccessToken,
@@ -506,6 +514,9 @@ export default function ProgressPortalPage() {
</div>
</div>
)}
{/* Search view only: someone following their device never sees it under the progress. */}
{showAccessHint && !session && <ProgressAccessHint hint={progressAccessHint(tenantStatus)} />}
</div>
</div>
</ProtectedRoute>
@@ -49,6 +49,8 @@ export type SessionTimeBreakdownDto = SessionTimeBreakdown;
const SEGMENT_META: { key: string; label: string; color: string }[] = [
{ key: "device_prep", label: "Device preparation", color: "bg-slate-400" },
{ key: "esp_apps", label: "Apps (ESP)", color: "bg-blue-500" },
{ key: "os_update", label: "Windows Update", color: "bg-cyan-500" },
{ key: "awaiting_sign_in", label: "Waiting for sign-in", color: "bg-sky-200" },
{ key: "identity_hello", label: "Identity & Hello", color: "bg-violet-500" },
{ key: "user_esp", label: "User ESP", color: "bg-indigo-400" },
{ key: "desktop_handoff", label: "Desktop handoff", color: "bg-emerald-500" },
@@ -83,8 +85,22 @@ export default function TimeAttributionLane({ breakdown }: { breakdown: SessionT
}
totals.set("unattributed", breakdown.unattributedSeconds);
// What the OOBE quality update installed and how often it restarted — named on the
// Windows Update slice (the server computed it; the client only joins the lists).
const osUpdates = breakdown.osUpdates ?? [];
const updateKbs = Array.from(new Set(osUpdates.flatMap(u => u.kbs ?? [])));
const updateRestarts = osUpdates.reduce((sum, u) => sum + (u.rebootCount ?? 0), 0);
const updateDetail = [
updateKbs.join(", "),
updateRestarts > 0 ? `${updateRestarts} restart${updateRestarts !== 1 ? "s" : ""}` : "",
].filter(Boolean).join(" · ");
const parts = SEGMENT_META
.map(meta => ({ ...meta, seconds: totals.get(meta.key) ?? 0 }))
.map(meta => ({
...meta,
seconds: totals.get(meta.key) ?? 0,
detail: meta.key === "os_update" ? updateDetail : "",
}))
.filter(p => p.seconds > 0);
const flags = breakdown.qualityFlags
@@ -132,7 +148,7 @@ export default function TimeAttributionLane({ breakdown }: { breakdown: SessionT
key={p.key}
className={`${p.color} h-full`}
style={{ width: `${(p.seconds / wallClock) * 100}%` }}
title={`${p.label}: ${formatDuration(p.seconds)}`}
title={`${p.label}: ${formatDuration(p.seconds)}${p.detail ? ` · ${p.detail}` : ""}`}
/>
))}
</div>
@@ -143,7 +159,7 @@ export default function TimeAttributionLane({ breakdown }: { breakdown: SessionT
<span key={p.key} className="inline-flex items-center text-xs text-gray-600">
<span className={`w-2.5 h-2.5 rounded-sm mr-1.5 ${p.color}`} />
{p.label}
<span className="ml-1 text-gray-400">{formatDuration(p.seconds)}</span>
<span className="ml-1 text-gray-400">{p.detail ? `${formatDuration(p.seconds)} · ${p.detail}` : formatDuration(p.seconds)}</span>
</span>
))}
</div>
@@ -320,10 +320,6 @@ interface TenantConfigContextValue {
addingAdmin: boolean;
removingAdmin: string | null;
togglingAdmin: string | null;
adminSearchQuery: string;
setAdminSearchQuery: (v: string) => void;
currentAdminPage: number;
setCurrentAdminPage: (v: SetStateAction<number>) => void;
handleAddAdmin: () => Promise<void>;
handleRemoveAdmin: (adminUpn: string) => Promise<void>;
handleToggleTenantAdmin: (adminUpn: string, isEnabled: boolean) => Promise<void>;
@@ -401,8 +397,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const [addingAdmin, setAddingAdmin] = useState(false);
const [removingAdmin, setRemovingAdmin] = useState<string | null>(null);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
const [adminSearchQuery, setAdminSearchQuery] = useState("");
const [currentAdminPage, setCurrentAdminPage] = useState(0);
// Offboard
const [showOffboardDialog, setShowOffboardDialog] = useState(false);
@@ -1382,7 +1376,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const handleRemoveAdmin = useCallback(async (adminUpn: string) => {
if (!tenantId) return;
if (!confirm(`Are you sure you want to remove ${adminUpn} as an admin?`)) return;
try {
setRemovingAdmin(adminUpn);
@@ -1677,8 +1670,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
newMemberRole, setNewMemberRole,
newMemberKind, setNewMemberKind,
addingAdmin, removingAdmin, togglingAdmin,
adminSearchQuery, setAdminSearchQuery,
currentAdminPage, setCurrentAdminPage,
handleAddAdmin, handleRemoveAdmin, handleToggleTenantAdmin, handleUpdatePermissions,
// Bootstrap sessions
@@ -1730,7 +1721,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
tenantDiagPaths, newDiagPath, newDiagDesc,
handleSaveDiagnostics, handleResetDiagnostics,
admins, loadingAdmins, newAdminEmail, newMemberRole, newMemberKind, addingAdmin, removingAdmin, togglingAdmin,
adminSearchQuery, currentAdminPage,
handleAddAdmin, handleRemoveAdmin, handleToggleTenantAdmin, handleUpdatePermissions,
bootstrapSessions, bootstrapLoading, fetchBootstrapSessions, createBootstrapSession, revokeBootstrapSession,
dataRetentionDays, sessionTimeoutHours, handleSaveDataManagement, handleResetDataManagement,
@@ -1,9 +1,12 @@
"use client";
import { useState } from "react";
import { TenantAdmin } from "../types";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { AddMemberForm } from "@/components/members/AddMemberForm";
import { MemberList } from "@/components/members/MemberList";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { isApplicationKey, looksLikeGuid, principalLabel, type MemberKind } from "@/utils/principalKeys";
import type { MemberKind } from "@/utils/principalKeys";
interface AdminManagementSectionProps {
admins: TenantAdmin[];
@@ -17,47 +20,15 @@ interface AdminManagementSectionProps {
addingAdmin: boolean;
removingAdmin: string | null;
togglingAdmin: string | null;
adminSearchQuery: string;
setAdminSearchQuery: (value: string) => void;
currentAdminPage: number;
setCurrentAdminPage: (value: number | ((prev: number) => number)) => void;
user: { upn?: string } | null;
/** The tenant also takes roles from Entra app-role assignments (an operator opt-in). */
entraAppRolesEnabled: boolean;
onAddAdmin: () => void;
onRemoveAdmin: (upn: string) => void;
onRemoveAdmin: (upn: string) => Promise<void>;
onToggleAdmin: (upn: string, isEnabled: boolean) => void;
onUpdatePermissions: (upn: string, role: string, canManageBootstrapTokens: boolean) => void;
}
function getRoleBadge(role: string | null | undefined) {
const effectiveRole = role ?? "Admin";
switch (effectiveRole) {
case "Admin":
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Admin
</span>
);
case "Operator":
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
Operator
</span>
);
case "Viewer":
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
Viewer
</span>
);
default:
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
{effectiveRole}
</span>
);
}
}
export default function AdminManagementSection({
admins,
loadingAdmins,
@@ -70,17 +41,15 @@ export default function AdminManagementSection({
addingAdmin,
removingAdmin,
togglingAdmin,
adminSearchQuery,
setAdminSearchQuery,
currentAdminPage,
setCurrentAdminPage,
user,
entraAppRolesEnabled,
onAddAdmin,
onRemoveAdmin,
onToggleAdmin,
onUpdatePermissions,
}: AdminManagementSectionProps) {
const addingApplication = newMemberKind === "application";
const [rolesOpen, setRolesOpen] = useState(false);
return (
<div className="bg-white rounded-lg shadow">
<SectionCardHeader
@@ -90,15 +59,31 @@ export default function AdminManagementSection({
subtitle="Manage team members and their roles for this tenant"
docsPath={DOCS_PATHS.accessManagement}
/>
<div className="p-6 space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="p-6 space-y-5">
<div className="bg-blue-50 border border-blue-200 rounded-lg">
<button
type="button"
aria-expanded={rolesOpen}
onClick={() => setRolesOpen((open) => !open)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-left"
>
<svg className="w-5 h-5 flex-shrink-0 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-800">
<p className="font-medium">About Roles</p>
<p className="mt-1">
<span className="flex-1 text-sm font-medium text-blue-800">About Roles</span>
<svg
className={`w-4 h-4 flex-shrink-0 text-blue-600 transition-transform ${rolesOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{rolesOpen && (
<div className="px-4 pb-3 pl-12 text-sm text-blue-800">
<p>
<strong>Member</strong> — No role assigned. Sees only the Progress Portal for their own enrollments.
</p>
<p className="mt-1">
@@ -114,279 +99,40 @@ export default function AdminManagementSection({
<strong>Your email:</strong> {user?.upn}
</p>
</div>
</div>
</div>
{/* Current Members List */}
<div>
<label className="block mb-2">
<span className="text-gray-700 font-medium">Current Team Members</span>
{loadingAdmins && (
<span className="ml-2 text-sm text-gray-500">(Loading...)</span>
)}
</label>
{/* Search Field */}
<div className="mb-3">
<div className="relative">
<input
type="text"
name="admin-search-field"
value={adminSearchQuery}
onChange={(e) => {
setAdminSearchQuery(e.target.value);
setCurrentAdminPage(0);
}}
placeholder="Search by email..."
autoComplete="off"
className="w-full px-4 py-2 pl-10 pr-10 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
/>
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{adminSearchQuery && (
<button
onClick={() => {
setAdminSearchQuery("");
setCurrentAdminPage(0);
}}
className="absolute right-3 top-2.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear search"
>
<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>
{admins.length === 0 && !loadingAdmins ? (
<div className="text-sm text-gray-500 italic">No members found</div>
) : (
<>
{/* Filtered and Paginated Member List */}
{(() => {
const filteredAdmins = admins.filter(admin =>
principalLabel(admin.upn).toLowerCase().includes(adminSearchQuery.toLowerCase())
);
if (filteredAdmins.length === 0) {
return (
<div className="text-sm text-gray-500 italic p-4 text-center bg-gray-50 rounded-lg">
No members match your search
</div>
);
}
const adminsPerPage = 3;
const totalAdminPages = Math.ceil(filteredAdmins.length / adminsPerPage);
const startAdminIndex = currentAdminPage * adminsPerPage;
const endAdminIndex = startAdminIndex + adminsPerPage;
const paginatedAdmins = filteredAdmins.slice(startAdminIndex, endAdminIndex);
return (
<>
<div className="space-y-2">
{paginatedAdmins.map((admin) => {
const isApplication = isApplicationKey(admin.upn);
const effectiveRole = isApplication ? "Viewer" : (admin.role ?? "Admin");
const isCurrentUser = admin.upn.toLowerCase() === user?.upn?.toLowerCase();
return (
<div
key={admin.upn}
className={`p-3 border rounded-lg ${
admin.isEnabled
? "bg-gray-50 border-gray-200"
: "bg-gray-100 border-gray-300"
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<div className="font-medium text-gray-900 truncate">{principalLabel(admin.upn)}</div>
{isApplication && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700" title="Service principal — automation with its own token, or an app whose users connect through it; read-only">
App
</span>
)}
{getRoleBadge(effectiveRole)}
{!admin.isEnabled && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-700">
Disabled
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
Added {new Date(admin.addedDate).toLocaleDateString()} by {admin.addedBy}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{isCurrentUser ? (
<span className="text-sm text-blue-600 font-medium">(You)</span>
) : (
<>
{/* Role change dropdown */}
<select
value={effectiveRole}
onChange={(e) => onUpdatePermissions(admin.upn, e.target.value, admin.canManageBootstrapTokens)}
disabled={isApplication || togglingAdmin === admin.upn}
title={isApplication ? "A service principal is always read-only (Viewer)" : undefined}
className="px-2 py-1 text-sm border border-gray-300 rounded bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-purple-500 disabled:opacity-50"
>
<option value="Admin">Admin</option>
<option value="Operator">Operator</option>
<option value="Viewer">Viewer</option>
</select>
<button
onClick={() => onToggleAdmin(admin.upn, admin.isEnabled)}
disabled={togglingAdmin === admin.upn}
className={`px-3 py-1 text-sm text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
admin.isEnabled
? "bg-yellow-600 hover:bg-yellow-700"
: "bg-green-600 hover:bg-green-700"
}`}
>
{togglingAdmin === admin.upn
? "..."
: admin.isEnabled
? "Disable"
: "Enable"}
</button>
<button
onClick={() => onRemoveAdmin(admin.upn)}
disabled={removingAdmin === admin.upn}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{removingAdmin === admin.upn ? "Removing..." : "Remove"}
</button>
</>
)}
</div>
</div>
{/* Bootstrap token permission toggle for Operators */}
{effectiveRole === "Operator" && !isCurrentUser && (
<div className="mt-2 pt-2 border-t border-gray-200">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={admin.canManageBootstrapTokens}
onChange={(e) => onUpdatePermissions(admin.upn, "Operator", e.target.checked)}
disabled={togglingAdmin === admin.upn}
className="h-4 w-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500 disabled:opacity-50"
/>
<span className="text-sm text-gray-700">Can manage bootstrap tokens</span>
</label>
</div>
)}
</div>
);
})}
</div>
{/* Pagination Controls */}
{totalAdminPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200">
<button
onClick={() => setCurrentAdminPage(prev => Math.max(0, prev - 1))}
disabled={currentAdminPage === 0}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {currentAdminPage + 1} of {totalAdminPages} ({filteredAdmins.length} member{filteredAdmins.length !== 1 ? 's' : ''})
</span>
<button
onClick={() => setCurrentAdminPage(prev => Math.min(totalAdminPages - 1, prev + 1))}
disabled={currentAdminPage >= totalAdminPages - 1}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</>
);
})()}
</>
)}
</div>
{/* Add New Member */}
<AddMemberForm
value={newAdminEmail}
onValueChange={setNewAdminEmail}
role={newMemberRole}
onRoleChange={setNewMemberRole}
kind={newMemberKind}
onKindChange={setNewMemberKind}
adding={addingAdmin}
onAdd={onAddAdmin}
/>
<div>
<label className="block mb-2">
<span className="text-gray-700 font-medium">Add New Team Member</span>
<div className="flex flex-wrap items-center gap-2 mb-2">
<p className="text-sm text-gray-500">
{addingApplication
? "Enter the application (client) ID of an app in your Entra tenant; it is always read-only (Viewer). Automation calling with its own token needs the access_as_application permission. An app that signs users in and sends their tokens needs the delegated access_as_user permission, and its users can only read. Grant either by admin consent in your Entra tenant."
: "Enter the user email (UPN) and select a role to grant access."}
</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="inline-flex rounded-lg border border-gray-300 overflow-hidden text-sm" role="group" aria-label="Member type">
{([["user", "User"], ["application", "Service principal"]] as const).map(([kind, label]) => (
<button
key={kind}
type="button"
onClick={() => { setNewMemberKind(kind); setNewAdminEmail(""); }}
className={`px-3 py-2 ${newMemberKind === kind ? "bg-purple-600 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`}
>
{label}
</button>
))}
</div>
<input
type={addingApplication ? "text" : "email"}
name="new-admin-email"
id="add-new-admin-email-input"
value={newAdminEmail}
onChange={(e) => setNewAdminEmail(e.target.value)}
placeholder={addingApplication ? "Application (client) ID, e.g. 00000000-0000-0000-0000-000000000000" : "user@tenant.com"}
autoComplete="off"
className="flex-1 min-w-0 px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onAddAdmin();
}
}}
/>
<select
value={addingApplication ? "Viewer" : newMemberRole}
onChange={(e) => setNewMemberRole(e.target.value)}
disabled={addingApplication}
title={addingApplication ? "A service principal is always read-only (Viewer)" : undefined}
className="px-3 py-2 border border-gray-300 rounded-lg text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors disabled:opacity-50"
>
<option value="Admin">Admin</option>
<option value="Operator">Operator</option>
<option value="Viewer">Viewer</option>
</select>
<button
onClick={onAddAdmin}
disabled={addingAdmin || !(addingApplication ? looksLikeGuid(newAdminEmail) : newAdminEmail.trim())}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
{addingAdmin ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Adding...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span>Add</span>
</>
)}
</button>
</div>
</label>
<h3 className="mb-2 text-gray-700 font-medium">
Team members
{loadingAdmins && <span className="ml-2 text-sm font-normal text-gray-500">(Loading...)</span>}
</h3>
{entraAppRolesEnabled && (
<p className="mb-2 text-xs text-gray-500">
Entra app roles are enabled for this tenant. Users with the Admin, Operator or Viewer app role on the enterprise application get that role when they sign in, without an entry here. An entry here always takes precedence, and a disabled entry blocks access.
</p>
)}
<MemberList
members={admins}
loading={loadingAdmins}
currentUserUpn={user?.upn}
removingUpn={removingAdmin}
updatingUpn={togglingAdmin}
onRemove={onRemoveAdmin}
onToggleEnabled={onToggleAdmin}
onUpdatePermissions={onUpdatePermissions}
/>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
@@ -7,13 +7,12 @@ import AdminManagementSection from "../../components/AdminManagementSection";
export function SectionAccessManagement() {
const {
canEditConfig,
config,
admins, loadingAdmins,
newAdminEmail, setNewAdminEmail,
newMemberRole, setNewMemberRole,
newMemberKind, setNewMemberKind,
addingAdmin, removingAdmin, togglingAdmin,
adminSearchQuery, setAdminSearchQuery,
currentAdminPage, setCurrentAdminPage,
user,
handleAddAdmin, handleRemoveAdmin,
handleToggleTenantAdmin, handleUpdatePermissions,
@@ -45,11 +44,8 @@ export function SectionAccessManagement() {
addingAdmin={addingAdmin}
removingAdmin={removingAdmin}
togglingAdmin={togglingAdmin}
adminSearchQuery={adminSearchQuery}
setAdminSearchQuery={setAdminSearchQuery}
currentAdminPage={currentAdminPage}
setCurrentAdminPage={setCurrentAdminPage}
user={user}
entraAppRolesEnabled={config?.entraAppRolesEnabled === true}
onAddAdmin={handleAddAdmin}
onRemoveAdmin={handleRemoveAdmin}
onToggleAdmin={handleToggleTenantAdmin}
@@ -141,6 +141,8 @@ export interface TenantConfiguration {
// Unrestricted mode
unrestrictedModeEnabled?: boolean;
unrestrictedMode?: boolean;
/** Roles may also come from Entra app-role assignments. GA-only, read-only here (Access Management hint). */
entraAppRolesEnabled?: boolean;
// Plan / edition (read-only here — mutated only via the dedicated plan/trial endpoints)
planTier?: string;
trialExpiresUtc?: string | null;
@@ -4,9 +4,8 @@ import { useAuth } from "../../contexts/AuthContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import type { Route } from "next";
import { trustedRoute } from "../../lib/routes";
import { landingTarget } from "../../lib/landingTarget";
import { consumePostLoginReturnUrl, peekPostLoginReturnUrl } from "../../lib/postLoginReturn";
import { hasOwnTenantOrPlatformRole, hasTenantReadScope } from "../../lib/tenantScope";
import { portalHandoverUrl, shouldCrossOriginToPortal } from "../../lib/hostRouting";
import { consumePendingRehome, getSelectedAuthApp, legacyConfigured, switchAuthApp, tryBeginRehome } from "../../lib/authApp";
import { activeAuthApp } from "../../lib/msalConfig";
@@ -39,22 +38,9 @@ export function AuthGate() {
// follows, and leaving it in place beats re-saving it.
// While activation is pending the link is only PEEKED as well: an invited customer admin whose
// tenant is still being activated must land on the invitation once activation completes, not on
// /dashboard — the link survives the /activation detour for the AuthGate pass that follows.
// /dashboard — the link survives the /activation detour, and the activation page consumes it.
const returnUrl = rehomeNow || isActivationPending ? peekPostLoginReturnUrl() : consumePostLoginReturnUrl();
let target: Route;
if (isActivationPending) {
target = "/activation";
} else if (returnUrl) {
// Restore the deep link the user originally opened before re-auth.
target = trustedRoute(returnUrl);
} else if (user.isDelegated && !hasOwnTenantOrPlatformRole(user)) {
// A delegated ("MSP") admin with no own-tenant/platform role manages a fleet → land on /fleet.
target = "/fleet";
} else if (hasTenantReadScope(user)) {
target = "/dashboard";
} else {
target = "/progress";
}
const target: Route = isActivationPending ? "/activation" : landingTarget(user, returnUrl);
// On the public host, hand over to the portal origin in ONE full-page
// navigation instead of router.push + HostRoutingGuard bounce. Auth state
// is per-origin — the portal side runs its own (silent) MSAL sign-in, on
@@ -0,0 +1,125 @@
"use client";
import { looksLikeGuid, type MemberKind } from "@/utils/principalKeys";
import { MEMBER_ROLES } from "./memberListModel";
interface AddMemberFormProps {
value: string;
onValueChange: (value: string) => void;
role: string;
onRoleChange: (role: string) => void;
kind: MemberKind;
onKindChange: (kind: MemberKind) => void;
adding: boolean;
onAdd: () => void;
/** Read-only caller: the form stays visible but cannot submit. */
disabled?: boolean;
/**
* The input always takes a line of its own (the GA tenant editor modal). Without it the input keeps a
* minimum width and wraps to the next line when the row has no room; the layout follows the space the
* host gives it, never a viewport breakpoint, because the same viewport can mean a wide page or a narrow
* column next to the sidebar.
*/
stacked?: boolean;
}
const KINDS = [["user", "User"], ["application", "Service principal"]] as const;
const USER_HINT = "Enter the user email (UPN) and select a role to grant access.";
const APPLICATION_HINT =
"Enter the application (client) ID of an app in your Entra tenant; it is always read-only (Viewer). Automation calling with its own token needs the access_as_application permission. An app that signs users in and sends their tokens needs the delegated access_as_user permission, and its users can only read. Grant either by admin consent in your Entra tenant.";
/** Adds a person by UPN or a service principal by application ID; a service principal is always a Viewer. */
export function AddMemberForm({
value,
onValueChange,
role,
onRoleChange,
kind,
onKindChange,
adding,
onAdd,
disabled = false,
stacked = false,
}: AddMemberFormProps) {
const addingApplication = kind === "application";
const valid = addingApplication ? looksLikeGuid(value) : value.trim().length > 0;
const canSubmit = !disabled && !adding && valid;
return (
<div>
<h3 className="text-gray-700 font-medium">Add member</h3>
<p className="mt-0.5 mb-2 text-sm text-gray-500">{addingApplication ? APPLICATION_HINT : USER_HINT}</p>
<div className="flex flex-wrap gap-2">
<div className="inline-flex rounded-lg border border-gray-300 overflow-hidden text-sm" role="group" aria-label="Member type">
{KINDS.map(([k, label]) => (
<button
key={k}
type="button"
aria-pressed={kind === k}
onClick={() => {
onKindChange(k);
onValueChange("");
}}
className={`px-3 py-2 ${kind === k ? "bg-purple-600 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`}
>
{label}
</button>
))}
</div>
<input
type={addingApplication ? "text" : "email"}
name="new-member"
value={value}
onChange={(e) => onValueChange(e.target.value)}
placeholder={addingApplication ? "Application (client) ID, e.g. 00000000-0000-0000-0000-000000000000" : "user@tenant.com"}
aria-label={addingApplication ? "Application (client) ID" : "User email (UPN)"}
autoComplete="off"
className={`${stacked ? "basis-full" : "grow basis-72"} min-w-0 px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors`}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (canSubmit) onAdd();
}
}}
/>
<div className="flex gap-2">
<select
value={addingApplication ? "Viewer" : role}
onChange={(e) => onRoleChange(e.target.value)}
disabled={addingApplication}
aria-label="Role"
title={addingApplication ? "A service principal is always read-only (Viewer)" : undefined}
className="px-3 py-2 border border-gray-300 rounded-lg text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors disabled:opacity-50"
>
{MEMBER_ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<button
type="button"
onClick={onAdd}
disabled={!canSubmit}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
{adding ? (
<>
<span className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" aria-hidden="true" />
Adding...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,427 @@
"use client";
import { useState } from "react";
import type { TenantAdminRow } from "@/utils/wire-types.generated";
import { isApplicationKey, principalLabel } from "@/utils/principalKeys";
import {
MEMBER_ROLES,
effectiveMemberFilter,
effectiveMemberRole,
matchesMemberFilter,
matchesMemberSearch,
memberCounts,
memberPage,
permissionChange,
sortMembers,
visibleMemberFilters,
type MemberFilter,
} from "./memberListModel";
interface MemberListProps {
members: TenantAdminRow[];
loading: boolean;
/** The signed-in user's UPN: that row shows "You" and offers no actions. */
currentUserUpn?: string | null;
/** False for a read-only caller: rows still open, their actions stay disabled. */
canMutate?: boolean;
/** The member whose removal is in flight. */
removingUpn: string | null;
/** The member whose role, bootstrap permission or enabled state is being saved. */
updatingUpn: string | null;
onRemove: (upn: string) => void | Promise<void>;
onToggleEnabled: (upn: string, isEnabled: boolean) => void;
onUpdatePermissions: (upn: string, role: string, canManageBootstrapTokens: boolean) => void;
}
const FILTER_LABEL: Record<MemberFilter, string> = {
all: "All",
Admin: "Admin",
Operator: "Operator",
Viewer: "Viewer",
disabled: "Disabled",
};
const ROLE_BADGE: Record<string, string> = {
Admin: "bg-green-100 text-green-800",
Operator: "bg-blue-100 text-blue-800",
Viewer: "bg-gray-100 text-gray-800",
};
const PILL = "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium";
const CONTEXT_PILL = `${PILL} bg-gray-100 border border-gray-300 text-gray-600`;
const APPLICATION_TITLE =
"Service principal — automation with its own token, or an app whose users connect through it; read-only";
/**
* Tenant members as one line each. A click opens a row with who added it and its actions (role,
* bootstrap permission, disable, remove with an in-row confirmation). Role chips with counts and the
* search narrow the list; it pages at 25 rows and never shows a page past the end.
*/
export function MemberList({
members,
loading,
currentUserUpn,
canMutate = true,
removingUpn,
updatingUpn,
onRemove,
onToggleEnabled,
onUpdatePermissions,
}: MemberListProps) {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<MemberFilter>("all");
const [page, setPage] = useState(0);
const [expandedUpn, setExpandedUpn] = useState<string | null>(null);
const [confirmRemoveUpn, setConfirmRemoveUpn] = useState<string | null>(null);
if (members.length === 0) {
return <p className="text-sm text-gray-500 italic">{loading ? "Loading members…" : "No members found"}</p>;
}
const counts = memberCounts(members);
const activeFilter = effectiveMemberFilter(filter, counts);
const matching = sortMembers(
members.filter((m) => matchesMemberFilter(m, activeFilter) && matchesMemberSearch(m, query)),
);
const shown = memberPage(matching, page);
const ownUpn = currentUserUpn?.toLowerCase();
// A vanished chip or a shrunken list moves the selection for good (guarded, converges after one extra
// render), so a member disabled or added later never snaps the list back to the old chip or page.
if (activeFilter !== filter) setFilter(activeFilter);
if (shown.page !== page) setPage(shown.page);
const changeQuery = (value: string) => {
setQuery(value);
setPage(0);
};
const toggleRow = (upn: string) => {
setExpandedUpn((open) => (open === upn ? null : upn));
setConfirmRemoveUpn(null);
};
const confirmRemove = async (upn: string) => {
await onRemove(upn);
setConfirmRemoveUpn(null);
};
return (
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div role="group" aria-label="Filter members by role" className="flex flex-wrap items-center gap-1.5">
{visibleMemberFilters(counts).map((f) => {
const active = f === activeFilter;
return (
<button
key={f}
type="button"
aria-pressed={active}
disabled={f !== "all" && counts[f] === 0}
onClick={() => {
setFilter(f);
setPage(0);
}}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
active
? "bg-purple-100 border-purple-300 text-purple-900"
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
}`}
>
{FILTER_LABEL[f]}
<span className={active ? "text-purple-700" : "text-gray-500"}>{counts[f]}</span>
</button>
);
})}
</div>
<div className="relative sm:ml-auto sm:w-64">
<input
type="text"
name="member-search"
value={query}
onChange={(e) => changeQuery(e.target.value)}
placeholder="Search members..."
aria-label="Search members"
autoComplete="off"
className="w-full pl-8 pr-8 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors"
/>
<svg className="absolute left-2.5 top-2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{query && (
<button
type="button"
onClick={() => changeQuery("")}
className="absolute right-2.5 top-2 text-gray-400 hover:text-gray-600 transition-colors"
title="Clear search"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{matching.length === 0 ? (
<p className="text-sm text-gray-500 italic p-4 text-center bg-gray-50 rounded-lg">No members match your search</p>
) : (
<ul className="border border-gray-200 rounded-lg divide-y divide-gray-100 bg-white">
{shown.rows.map((m) => (
<MemberRow
key={m.upn}
member={m}
open={expandedUpn === m.upn}
confirmingRemove={confirmRemoveUpn === m.upn}
isCurrentUser={ownUpn !== undefined && m.upn.toLowerCase() === ownUpn}
canMutate={canMutate}
removing={removingUpn === m.upn}
updating={updatingUpn === m.upn}
onToggleOpen={() => toggleRow(m.upn)}
onAskRemove={() => setConfirmRemoveUpn(m.upn)}
onCancelRemove={() => setConfirmRemoveUpn(null)}
onConfirmRemove={() => void confirmRemove(m.upn)}
onToggleEnabled={() => onToggleEnabled(m.upn, m.isEnabled)}
onSavePermissions={(role, canManageBootstrapTokens) => onUpdatePermissions(m.upn, role, canManageBootstrapTokens)}
/>
))}
</ul>
)}
{shown.pageCount > 1 && (
<div className="flex items-center justify-between pt-1">
<button
type="button"
onClick={() => setPage(shown.page - 1)}
disabled={shown.page === 0}
className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-xs text-gray-600">
{`Page ${shown.page + 1} of ${shown.pageCount} (${matching.length} members)`}
</span>
<button
type="button"
onClick={() => setPage(shown.page + 1)}
disabled={shown.page >= shown.pageCount - 1}
className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</div>
);
}
interface MemberRowProps {
member: TenantAdminRow;
open: boolean;
confirmingRemove: boolean;
isCurrentUser: boolean;
canMutate: boolean;
removing: boolean;
updating: boolean;
onToggleOpen: () => void;
onAskRemove: () => void;
onCancelRemove: () => void;
onConfirmRemove: () => void;
onToggleEnabled: () => void;
onSavePermissions: (role: string, canManageBootstrapTokens: boolean) => void;
}
function MemberRow({ member, open, onToggleOpen, ...details }: MemberRowProps) {
const isApplication = isApplicationKey(member.upn);
const role = effectiveMemberRole(member);
const label = principalLabel(member.upn);
const added = new Date(member.addedDate).toLocaleDateString();
return (
<li className={open ? "bg-gray-50" : undefined}>
<button
type="button"
aria-expanded={open}
onClick={onToggleOpen}
className="w-full flex items-start sm:items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-purple-500"
>
<span className="flex min-w-0 flex-1 flex-wrap sm:flex-nowrap items-center gap-x-2 gap-y-1">
<span
className={`min-w-0 basis-full sm:basis-auto break-all sm:truncate font-medium ${member.isEnabled ? "text-gray-900" : "text-gray-500"}`}
title={label}
>
{label}
</span>
<span className="flex flex-shrink-0 flex-wrap items-center gap-1.5">
{details.isCurrentUser && <span className={CONTEXT_PILL}>You</span>}
{isApplication && (
<span className={`${PILL} bg-gray-100 text-gray-700`} title={APPLICATION_TITLE}>
App
</span>
)}
<span className={`${PILL} ${ROLE_BADGE[role] ?? "bg-gray-100 text-gray-600"}`}>{role}</span>
{role === "Operator" && member.canManageBootstrapTokens && (
<span className={CONTEXT_PILL} title="Can manage bootstrap tokens">
Bootstrap tokens
</span>
)}
{!member.isEnabled && <span className={`${PILL} bg-gray-200 text-gray-700`}>Disabled</span>}
</span>
</span>
<span className="hidden sm:inline flex-shrink-0 text-xs text-gray-400">{`Added ${added}`}</span>
<svg
className={`mt-0.5 sm:mt-0 h-4 w-4 flex-shrink-0 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && <MemberDetails member={member} label={label} added={added} {...details} />}
</li>
);
}
interface MemberDetailsProps extends Omit<MemberRowProps, "open" | "onToggleOpen"> {
label: string;
added: string;
}
/**
* The open part of a row. Role and bootstrap permission stay drafts until Save, so a stray select change
* never alters anyone's access; the part unmounts with the closed row, which discards an unsaved draft.
*/
function MemberDetails({
member,
label,
added,
confirmingRemove,
isCurrentUser,
canMutate,
removing,
updating,
onAskRemove,
onCancelRemove,
onConfirmRemove,
onToggleEnabled,
onSavePermissions,
}: MemberDetailsProps) {
const isApplication = isApplicationKey(member.upn);
const role = effectiveMemberRole(member);
const [draftRole, setDraftRole] = useState(role);
const [draftBootstrap, setDraftBootstrap] = useState(member.canManageBootstrapTokens);
const change = permissionChange(member, draftRole, draftBootstrap);
const busy = removing || updating;
const discard = () => {
setDraftRole(role);
setDraftBootstrap(member.canManageBootstrapTokens);
};
return (
<div className="space-y-2 px-3 pb-3 text-sm">
<p className="text-xs text-gray-500">{`Added ${added} by ${member.addedBy}`}</p>
{isCurrentUser ? (
<p className="text-xs text-gray-500">This is your own entry. Only another admin can change its role or access.</p>
) : (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<label className="flex items-center gap-2 text-gray-700">
Role
<select
value={draftRole}
onChange={(e) => setDraftRole(e.target.value)}
disabled={!canMutate || isApplication || busy}
title={isApplication ? "A service principal is always read-only (Viewer)" : undefined}
className="px-2 py-1 text-sm border border-gray-300 rounded bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-purple-500 disabled:opacity-50"
>
{MEMBER_ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
{draftRole === "Operator" && (
<label className="flex items-center gap-2 text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={draftBootstrap}
onChange={(e) => setDraftBootstrap(e.target.checked)}
disabled={!canMutate || busy}
className="h-4 w-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500 disabled:opacity-50"
/>
Can manage bootstrap tokens
</label>
)}
{!isApplication && (
<span className="flex items-center gap-2">
<button
type="button"
onClick={() => onSavePermissions(draftRole, change.canManageBootstrapTokens)}
disabled={!canMutate || busy || !change.dirty}
className="px-3 py-1 text-sm bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Save
</button>
{change.dirty && (
<button
type="button"
onClick={discard}
disabled={busy}
className="px-2 py-1 text-sm text-gray-600 hover:text-gray-800 disabled:opacity-50"
>
Discard
</button>
)}
</span>
)}
<span className="flex flex-wrap items-center gap-2 sm:ml-auto">
{confirmingRemove ? (
<>
<span className="text-xs text-gray-600">{`Remove ${label}?`}</span>
<button
type="button"
onClick={onConfirmRemove}
disabled={!canMutate || busy}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{removing ? "Removing..." : "Remove"}
</button>
<button
type="button"
onClick={onCancelRemove}
disabled={removing}
className="px-2 py-1 text-sm text-gray-600 hover:text-gray-800 disabled:opacity-50"
>
Cancel
</button>
</>
) : (
<>
<button
type="button"
onClick={onToggleEnabled}
disabled={!canMutate || busy}
className={`px-3 py-1 text-sm text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
member.isEnabled ? "bg-yellow-600 hover:bg-yellow-700" : "bg-green-600 hover:bg-green-700"
}`}
>
{updating ? "..." : member.isEnabled ? "Disable" : "Enable"}
</button>
<button
type="button"
onClick={onAskRemove}
disabled={!canMutate || busy}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Remove
</button>
</>
)}
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { AddMemberForm } from "../AddMemberForm";
const noop = () => {};
function render(stacked?: boolean): string {
return renderToStaticMarkup(
createElement(AddMemberForm, {
value: "",
onValueChange: noop,
role: "Admin",
onRoleChange: noop,
kind: "user",
onKindChange: noop,
adding: false,
onAdd: noop,
stacked,
}),
);
}
function inputClasses(html: string): string[] {
const input = html.match(/<input [^>]*name="new-member"[^>]*>/)?.[0] ?? "";
return (input.match(/class="([^"]*)"/)?.[1] ?? "").split(" ");
}
describe("AddMemberForm", () => {
it("keeps a minimum width for the input and wraps instead of squeezing it, whatever the viewport", () => {
const classes = inputClasses(render());
expect(classes).toEqual(expect.arrayContaining(["grow", "basis-72", "min-w-0"]));
// No viewport breakpoint decides the layout: a wide window can still host a narrow column.
expect(classes.filter((c) => /^(sm|md|lg|xl):/.test(c))).toEqual([]);
});
it("gives the input a line of its own when stacked (the GA tenant editor modal)", () => {
const classes = inputClasses(render(true));
expect(classes).toContain("basis-full");
expect(classes).not.toContain("basis-72");
});
it("keeps role and Add together, so they wrap as one unit", () => {
const html = render();
expect(html).toMatch(/<div class="flex gap-2"><select [^>]*aria-label="Role"[\s\S]*?<\/select><button [^>]*>[\s\S]*?Add<\/button><\/div>/);
});
});
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { TenantAdminRow } from "@/utils/wire-types.generated";
import { MemberList } from "../MemberList";
import { MEMBER_PAGE_SIZE } from "../memberListModel";
const noop = () => {};
function row(upn: string, overrides: Partial<TenantAdminRow> = {}): TenantAdminRow {
return {
tenantId: "11111111-2222-3333-4444-555555555555",
upn,
isEnabled: true,
addedDate: "2026-09-01T10:00:00Z",
addedBy: "first.admin@tenant.example",
role: "Viewer",
canManageBootstrapTokens: false,
...overrides,
};
}
function render(members: TenantAdminRow[], currentUserUpn?: string) {
return renderToStaticMarkup(
createElement(MemberList, {
members,
loading: false,
currentUserUpn,
removingUpn: null,
updatingUpn: null,
onRemove: noop,
onToggleEnabled: noop,
onUpdatePermissions: noop,
}),
);
}
describe("MemberList", () => {
it("renders closed one-line rows: badges only, the actions wait behind a click", () => {
const html = render([
row("admin@tenant.example", { role: "Admin" }),
row("op@tenant.example", { role: "Operator", canManageBootstrapTokens: true }),
row("off@tenant.example", { isEnabled: false }),
]);
expect(html.match(/aria-expanded="false"/g)?.length).toBe(3);
expect(html).toContain("Bootstrap tokens");
expect(html).toContain(">Disabled</span>");
expect(html).not.toContain("<select");
expect(html).not.toContain(">Remove</button>");
expect(html).not.toContain(">Disable</button>");
});
it("shows the role chips with counts, All pressed, and Disabled only when an entry is disabled", () => {
const html = render([
row("a@tenant.example", { role: "Admin" }),
row("b@tenant.example", { role: "Viewer" }),
row("c@tenant.example", { role: "Viewer" }),
]);
const chips = html.split('<div role="group" aria-label="Filter members by role"')[1].split("</div>")[0];
expect(chips).toContain('aria-pressed="true"');
expect(chips.match(/<button /g)?.length).toBe(4);
expect(chips).toMatch(/All<span[^>]*>3<\/span>/);
expect(chips).toMatch(/Viewer<span[^>]*>2<\/span>/);
// No Operator yet: the chip stays in place but cannot be picked.
expect(chips).toMatch(/disabled=""[^>]*>Operator<span[^>]*>0<\/span>/);
expect(chips).not.toContain("Disabled");
});
it("marks the signed-in user's row", () => {
const html = render([row("me@tenant.example", { role: "Admin" }), row("other@tenant.example")], "ME@tenant.example");
const ownRow = html.split("<li").find((chunk) => chunk.includes("me@tenant.example"));
expect(ownRow).toContain(">You</span>");
const otherRow = html.split("<li").find((chunk) => chunk.includes("other@tenant.example"));
expect(otherRow).not.toContain(">You</span>");
});
it(`pages at ${MEMBER_PAGE_SIZE} rows`, () => {
const members = Array.from({ length: MEMBER_PAGE_SIZE + 5 }, (_, i) => row(`user${String(i).padStart(2, "0")}@tenant.example`));
const html = render(members);
expect(html.match(/<li/g)?.length).toBe(MEMBER_PAGE_SIZE);
expect(html).toContain(`Page 1 of 2 (${MEMBER_PAGE_SIZE + 5} members)`);
});
it("renders no pager for one page and an empty state without members", () => {
expect(render([row("a@tenant.example")])).not.toContain("Next");
expect(render([])).toContain("No members found");
});
});
@@ -0,0 +1,152 @@
import { describe, it, expect } from "vitest";
import type { TenantAdminRow } from "@/utils/wire-types.generated";
import {
MEMBER_PAGE_SIZE,
effectiveMemberFilter,
effectiveMemberRole,
matchesMemberFilter,
matchesMemberSearch,
memberCounts,
memberPage,
permissionChange,
sortMembers,
visibleMemberFilters,
} from "../memberListModel";
const APP_ID = "0f0e0d0c-1111-2222-3333-444455556666";
function row(upn: string, overrides: Partial<TenantAdminRow> = {}): TenantAdminRow {
return {
tenantId: "11111111-2222-3333-4444-555555555555",
upn,
isEnabled: true,
addedDate: "2026-09-01T10:00:00Z",
addedBy: "first.admin@tenant.example",
role: "Viewer",
canManageBootstrapTokens: false,
...overrides,
};
}
describe("effectiveMemberRole", () => {
it("reads a row without a role as Admin (pre-role rows)", () => {
expect(effectiveMemberRole({ upn: "legacy@tenant.example" })).toBe("Admin");
expect(effectiveMemberRole({ upn: "op@tenant.example", role: "Operator" })).toBe("Operator");
});
it("caps a service principal at Viewer whatever its row says", () => {
expect(effectiveMemberRole({ upn: `app:${APP_ID}`, role: "Admin" })).toBe("Viewer");
expect(effectiveMemberRole({ upn: `APP:${APP_ID}` })).toBe("Viewer");
});
});
describe("memberCounts", () => {
it("counts every role by its effective value and the disabled entries on top", () => {
const counts = memberCounts([
row("a@tenant.example", { role: undefined }),
row("b@tenant.example", { role: "Admin", isEnabled: false }),
row("c@tenant.example", { role: "Operator" }),
row("d@tenant.example"),
row(`app:${APP_ID}`, { role: "Admin" }),
]);
expect(counts).toEqual({ all: 5, Admin: 2, Operator: 1, Viewer: 2, disabled: 1 });
});
});
describe("chips", () => {
it("always offers All and the three roles, Disabled only while an entry is disabled", () => {
const enabledOnly = memberCounts([row("a@tenant.example", { role: "Admin" })]);
expect(visibleMemberFilters(enabledOnly)).toEqual(["all", "Admin", "Operator", "Viewer"]);
const withDisabled = memberCounts([row("a@tenant.example", { isEnabled: false })]);
expect(visibleMemberFilters(withDisabled)).toEqual(["all", "Admin", "Operator", "Viewer", "disabled"]);
});
it("falls back to All when the selected chip has no entries left", () => {
const counts = memberCounts([row("a@tenant.example", { role: "Admin" })]);
expect(effectiveMemberFilter("Admin", counts)).toBe("Admin");
expect(effectiveMemberFilter("Operator", counts)).toBe("all");
expect(effectiveMemberFilter("disabled", counts)).toBe("all");
expect(effectiveMemberFilter("all", memberCounts([]))).toBe("all");
});
it("filters by effective role or by the disabled state", () => {
const disabledAdmin = row("b@tenant.example", { role: "Admin", isEnabled: false });
expect(matchesMemberFilter(disabledAdmin, "Admin")).toBe(true);
expect(matchesMemberFilter(disabledAdmin, "disabled")).toBe(true);
expect(matchesMemberFilter(disabledAdmin, "Viewer")).toBe(false);
expect(matchesMemberFilter(row(`app:${APP_ID}`, { role: "Admin" }), "Viewer")).toBe(true);
expect(matchesMemberFilter(row("c@tenant.example"), "all")).toBe(true);
});
});
describe("matchesMemberSearch", () => {
it("matches the shown name ignoring case and surrounding spaces", () => {
expect(matchesMemberSearch(row("Jane.Doe@tenant.example"), " jane.DOE ")).toBe(true);
expect(matchesMemberSearch(row("jane.doe@tenant.example"), "john")).toBe(false);
expect(matchesMemberSearch(row("jane.doe@tenant.example"), "")).toBe(true);
});
it("finds a service principal by its label or client id, not by the key prefix", () => {
const app = row(`app:${APP_ID}`);
expect(matchesMemberSearch(app, "service principal")).toBe(true);
expect(matchesMemberSearch(app, APP_ID.slice(0, 8))).toBe(true);
expect(matchesMemberSearch(app, "app:")).toBe(false);
});
});
describe("sortMembers", () => {
it("lists people alphabetically before service principals and leaves the input untouched", () => {
const input = [
row(`app:${APP_ID}`),
row("zoe@tenant.example"),
row("Anna@tenant.example"),
row("bert@tenant.example"),
];
const sorted = sortMembers(input).map((m) => m.upn);
expect(sorted).toEqual(["Anna@tenant.example", "bert@tenant.example", "zoe@tenant.example", `app:${APP_ID}`]);
expect(input[0].upn).toBe(`app:${APP_ID}`);
});
});
describe("permissionChange", () => {
const operator = row("op@tenant.example", { role: "Operator", canManageBootstrapTokens: true });
it("has nothing to save while the draft equals the stored row", () => {
expect(permissionChange(operator, "Operator", true)).toEqual({ dirty: false, canManageBootstrapTokens: true });
expect(permissionChange(row("legacy@tenant.example", { role: undefined }), "Admin", false).dirty).toBe(false);
});
it("saves a role change and keeps the stored bootstrap flag for a non-Operator role", () => {
expect(permissionChange(operator, "Viewer", false)).toEqual({ dirty: true, canManageBootstrapTokens: true });
});
it("saves the bootstrap draft only for an Operator", () => {
expect(permissionChange(operator, "Operator", false)).toEqual({ dirty: true, canManageBootstrapTokens: false });
const viewer = row("v@tenant.example", { role: "Viewer", canManageBootstrapTokens: false });
expect(permissionChange(viewer, "Viewer", true)).toEqual({ dirty: false, canManageBootstrapTokens: false });
expect(permissionChange(viewer, "Operator", true)).toEqual({ dirty: true, canManageBootstrapTokens: true });
});
});
describe("memberPage", () => {
const rows = Array.from({ length: 2 * MEMBER_PAGE_SIZE + 3 }, (_, i) => i);
it("slices full pages and a short last page", () => {
expect(memberPage(rows, 0)).toMatchObject({ page: 0, pageCount: 3 });
expect(memberPage(rows, 0).rows).toHaveLength(MEMBER_PAGE_SIZE);
expect(memberPage(rows, 2).rows).toEqual([50, 51, 52]);
});
it("clamps a page past the end to the last page, so a shrunken list never shows an empty page", () => {
const shrunk = rows.slice(0, MEMBER_PAGE_SIZE + 1);
const shown = memberPage(shrunk, 2);
expect(shown.page).toBe(1);
expect(shown.rows).toEqual([MEMBER_PAGE_SIZE]);
expect(memberPage(rows, -4).page).toBe(0);
});
it("keeps one empty page for an empty list", () => {
expect(memberPage([], 3)).toEqual({ rows: [], page: 0, pageCount: 1 });
});
});
@@ -0,0 +1,108 @@
import type { TenantAdminRow } from "@/utils/wire-types.generated";
import { isApplicationKey, principalLabel } from "@/utils/principalKeys";
/** Tenant roles a member row can grant, highest first. */
export const MEMBER_ROLES = ["Admin", "Operator", "Viewer"] as const;
export type MemberRole = (typeof MEMBER_ROLES)[number];
/** Chip filter of the member list: every member, one role, or the disabled entries. */
export type MemberFilter = "all" | MemberRole | "disabled";
/** Rows per page: one-line rows, so a large tenant fits on one or two pages. */
export const MEMBER_PAGE_SIZE = 25;
type MemberFields = Pick<TenantAdminRow, "upn" | "role" | "isEnabled">;
/**
* The role a row grants. A row without a role predates roles and is an Admin; a service principal is
* always read-only, whatever its row says (the backend caps it the same way).
*/
export function effectiveMemberRole(row: Pick<TenantAdminRow, "upn" | "role">): string {
if (isApplicationKey(row.upn)) return "Viewer";
return row.role ?? "Admin";
}
export type MemberCounts = Record<MemberFilter, number>;
export function memberCounts(rows: readonly MemberFields[]): MemberCounts {
const counts: MemberCounts = { all: rows.length, Admin: 0, Operator: 0, Viewer: 0, disabled: 0 };
for (const row of rows) {
const role = effectiveMemberRole(row);
if (role === "Admin" || role === "Operator" || role === "Viewer") counts[role]++;
if (!row.isEnabled) counts.disabled++;
}
return counts;
}
/**
* The chips the list shows: All and the three roles always, so the row does not jump while counts change;
* Disabled only while an entry is disabled.
*/
export function visibleMemberFilters(counts: MemberCounts): MemberFilter[] {
const filters: MemberFilter[] = ["all", ...MEMBER_ROLES];
if (counts.disabled > 0) filters.push("disabled");
return filters;
}
/** A selected chip whose last entry was removed or changed falls back to All instead of an empty list. */
export function effectiveMemberFilter(filter: MemberFilter, counts: MemberCounts): MemberFilter {
return filter === "all" || counts[filter] > 0 ? filter : "all";
}
export function matchesMemberFilter(row: MemberFields, filter: MemberFilter): boolean {
if (filter === "all") return true;
if (filter === "disabled") return !row.isEnabled;
return effectiveMemberRole(row) === filter;
}
/** Search matches the shown name (UPN, or "Service principal <client-id>"), ignoring case. */
export function matchesMemberSearch(row: Pick<TenantAdminRow, "upn">, query: string): boolean {
const q = query.trim().toLowerCase();
return q === "" || principalLabel(row.upn).toLowerCase().includes(q);
}
/** People first, then service principals; each group alphabetically by the shown name. */
export function sortMembers<T extends Pick<TenantAdminRow, "upn">>(rows: readonly T[]): T[] {
return [...rows].sort((a, b) => {
const appA = isApplicationKey(a.upn);
const appB = isApplicationKey(b.upn);
if (appA !== appB) return appA ? 1 : -1;
return principalLabel(a.upn).localeCompare(principalLabel(b.upn), undefined, { sensitivity: "base" });
});
}
export interface PermissionChange {
/** Something differs from the stored row, so Save has work to do. */
dirty: boolean;
/** The bootstrap flag Save writes: the draft for an Operator, otherwise the stored value unchanged. */
canManageBootstrapTokens: boolean;
}
/**
* The write behind an open row's Save button. Role and bootstrap permission are drafts until Save, so a
* stray select change never alters anyone's access on its own.
*/
export function permissionChange(
row: Pick<TenantAdminRow, "upn" | "role" | "canManageBootstrapTokens">,
draftRole: string,
draftBootstrap: boolean,
): PermissionChange {
const canManageBootstrapTokens = draftRole === "Operator" ? draftBootstrap : row.canManageBootstrapTokens;
return {
dirty: draftRole !== effectiveMemberRole(row) || canManageBootstrapTokens !== row.canManageBootstrapTokens,
canManageBootstrapTokens,
};
}
export interface MemberPage<T> {
rows: T[];
/** The page actually shown — clamped, so a list that shrank never shows an empty page. */
page: number;
pageCount: number;
}
export function memberPage<T>(rows: readonly T[], page: number, pageSize: number = MEMBER_PAGE_SIZE): MemberPage<T> {
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
const shown = Math.min(Math.max(0, page), pageCount - 1);
return { rows: rows.slice(shown * pageSize, (shown + 1) * pageSize), page: shown, pageCount };
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { landingTarget } from "../landingTarget";
import type { TenantScopeUser } from "../tenantScope";
function user(overrides: Partial<TenantScopeUser> = {}): TenantScopeUser {
return {
isTenantAdmin: false,
isGlobalAdmin: false,
isGlobalReader: false,
isDelegated: false,
role: null,
...overrides,
};
}
describe("landingTarget", () => {
it("restores the deep link the user opened, whatever their scope", () => {
expect(landingTarget(user(), "/delegations/accept?token=abc")).toBe("/delegations/accept?token=abc");
expect(landingTarget(user({ isTenantAdmin: true, role: "Admin" }), "/sessions/x")).toBe("/sessions/x");
});
it("sends every tenant role to the dashboard, not only the admin", () => {
expect(landingTarget(user({ isTenantAdmin: true, role: "Admin" }), null)).toBe("/dashboard");
expect(landingTarget(user({ role: "Operator" }), null)).toBe("/dashboard");
expect(landingTarget(user({ role: "Viewer" }), null)).toBe("/dashboard");
});
it("sends platform scope to the dashboard", () => {
expect(landingTarget(user({ isGlobalAdmin: true }), null)).toBe("/dashboard");
expect(landingTarget(user({ isGlobalReader: true }), null)).toBe("/dashboard");
});
it("sends a delegated admin without an own-tenant role to the fleet", () => {
expect(landingTarget(user({ isDelegated: true }), null)).toBe("/fleet");
});
it("keeps a delegated admin with an own-tenant role on the dashboard", () => {
expect(landingTarget(user({ isDelegated: true, role: "Viewer" }), null)).toBe("/dashboard");
});
it("sends a member without a role to the Progress Portal", () => {
expect(landingTarget(user(), null)).toBe("/progress");
});
});
+2
View File
@@ -431,6 +431,8 @@ export const api = {
`${API_BASE_URL}/api/progress/sessions/lookup${qs({ tenantId, search })}`,
sessionEvents: (sessionId: string, tenantId: string, serial: string) =>
`${API_BASE_URL}/api/progress/sessions/${encodeURIComponent(sessionId)}/events${qs({ tenantId, serial })}`,
/** GET — whether the caller's own organization looks unused (JWT-scoped, no session data). */
tenantStatus: () => `${API_BASE_URL}/api/progress/tenant-status`,
},
// ── Bootstrap ─────────────────────────────────────────────────────────────
@@ -57,6 +57,8 @@ export const DOCS_PATHS = {
appRegistrationMigrationAfter: "/troubleshooting-and-support/app-registration-migration#after-the-migration",
appRegistrationMigrationAddOns: "/troubleshooting-and-support/app-registration-migration#optional-graph-add-on-permissions",
optionalGraphPermissions: "/reference/optional-graph-permissions",
// Progress Portal access hint (members without a role)
progressPortalOnly: "/troubleshooting-and-support/progress-portal-only",
} as const;
/** Top-level URL segments GitBook publishes for docs.autopilotmonitor.com (SUMMARY.md groups + root pages). */
@@ -0,0 +1,16 @@
import type { Route } from "next";
import { trustedRoute } from "./routes";
import { hasOwnTenantOrPlatformRole, hasTenantReadScope, type TenantScopeUser } from "./tenantScope";
/**
* Where a signed-in user of an activated tenant lands: the deep link they originally opened, else
* their home by scope. The landing gate and the activation page share it, so leaving activation
* lands a user exactly where any later sign-in would.
*/
export function landingTarget(user: TenantScopeUser, returnUrl: string | null): Route {
if (returnUrl) return trustedRoute(returnUrl);
// A delegated ("MSP") admin with no own-tenant or platform role manages a fleet.
if (user.isDelegated && !hasOwnTenantOrPlatformRole(user)) return "/fleet";
if (hasTenantReadScope(user)) return "/dashboard";
return "/progress";
}
@@ -0,0 +1,2 @@
/** The support mailbox: the one address the site names for writing to the team. */
export const SUPPORT_EMAIL = "support@autopilotmonitor.com";
@@ -14369,6 +14369,53 @@
}
]
},
"OsUpdateSpan": {
"kind": "object",
"doc": "One OOBE quality update behind an OsUpdate span (D-310) — the interval and what the evidence names. Annotation like RebootSpan; the partition itself is in Segments.",
"fields": [
{
"name": "startUtc",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "endUtc",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "seconds",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "In-window seconds of the interval."
},
{
"name": "kbs",
"type": {
"kind": "array",
"element": {
"kind": "primitive",
"name": "string"
}
},
"doc": "KB numbers of the packages Windows serviced in the interval (\"KB5129195\"), in order of first appearance; empty when no package named one."
},
{
"name": "rebootCount",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Restarts that began inside the interval."
}
]
},
"PatchAdminConfigurationRequest": {
"kind": "object",
"doc": "Body of PATCH global/config: { \"fields\": { <fieldName>: <value>, ... } } with at least one field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take the field's own JSON type.",
@@ -15090,6 +15137,36 @@
}
]
},
"ProgressTenantStatusResponse": {
"kind": "object",
"doc": "Response of GET /api/progress/tenant-status — whether the caller's own organization looks unused, so the Progress Portal can tell a member without a role whom to ask for access.",
"fields": [
{
"name": "success",
"type": {
"kind": "primitive",
"name": "boolean"
}
},
{
"name": "unused",
"type": {
"kind": "primitive",
"name": "boolean"
},
"doc": "True when the organization holds no session and signed up more than 14 days ago."
},
{
"name": "signedUpAt",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "When the organization signed up; null (key omitted on the wire) when unknown."
}
]
},
"QueryBackendLogsErrorResponse": {
"kind": "object",
"doc": "Error body of POST /api/global/raw/logs when the telemetry store rejected or failed the query (400 for a caller-side KQL error, 502 for store/grant failures): the envelope prefix plus the store's own error code, HTTP status and — capped — its full response, so nothing the CLI would print is lost. hint tells the caller how to fix the query.",
@@ -19998,6 +20075,17 @@
}
}
},
{
"name": "osUpdates",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "OsUpdateSpan"
}
},
"doc": "The OOBE quality updates behind the OsUpdate spans (at most one per observation window)."
},
{
"name": "blockingApps",
"type": {
@@ -22486,7 +22574,7 @@
"kind": "primitive",
"name": "boolean"
},
"doc": "When enabled, tenant member roles (Admin / Operator) may also be granted via Entra ID app-role assignments on the application's Enterprise App (the \"roles\" claim in the user's token), in addition to the TenantAdmins table. Resolution is table-first: an explicit TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator CanManageBootstrapTokens). Only Admin and Operator are claim-mappable; the platform-wide GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in. Backend-only setting: not delivered to the agent (no ConfigVersion impact)."
"doc": "When enabled, tenant member roles (Admin / Operator / Viewer) may also be granted via Entra ID app-role assignments on the application's Enterprise App (the \"roles\" claim in the user's token), in addition to the TenantAdmins table. Resolution is table-first: an explicit TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator CanManageBootstrapTokens). The platform-wide GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in. Backend-only setting: not delivered to the agent (no ConfigVersion impact)."
},
{
"name": "diagnosticsLogPathsJson",
@@ -3054,6 +3054,18 @@ export interface OpsEventListResponse {
nextLink?: string;
}
/** One OOBE quality update behind an OsUpdate span (D-310) — the interval and what the evidence names. Annotation like RebootSpan; the partition itself is in Segments. */
export interface OsUpdateSpan {
startUtc: string;
endUtc: string;
/** In-window seconds of the interval. */
seconds: number;
/** KB numbers of the packages Windows serviced in the interval ("KB5129195"), in order of first appearance; empty when no package named one. */
kbs: string[];
/** Restarts that began inside the interval. */
rebootCount: number;
}
/** Body of PATCH global/config: { "fields": { <fieldName>: <value>, ... } } with at least one field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take the field's own JSON type. */
export interface PatchAdminConfigurationRequest {
fields?: Record<string, unknown>;
@@ -3231,6 +3243,15 @@ export interface ProgressLookupSessionResponse {
session?: SessionSummary;
}
/** Response of GET /api/progress/tenant-status — whether the caller's own organization looks unused, so the Progress Portal can tell a member without a role whom to ask for access. */
export interface ProgressTenantStatusResponse {
success: boolean;
/** True when the organization holds no session and signed up more than 14 days ago. */
unused: boolean;
/** When the organization signed up; null (key omitted on the wire) when unknown. */
signedUpAt?: string;
}
/** Error body of POST /api/global/raw/logs when the telemetry store rejected or failed the query (400 for a caller-side KQL error, 502 for store/grant failures): the envelope prefix plus the store's own error code, HTTP status and — capped — its full response, so nothing the CLI would print is lost. hint tells the caller how to fix the query. */
export interface QueryBackendLogsErrorResponse {
error: string;
@@ -4334,6 +4355,8 @@ export interface SessionTimeBreakdown {
/** Total observed in-window sleep seconds. Cross-cutting annotation like RebootSeconds — the wall clock keeps the pause; this discloses it. */
sleepSeconds: number;
sleepSpans: SleepSpan[];
/** The OOBE quality updates behind the OsUpdate spans (at most one per observation window). */
osUpdates: OsUpdateSpan[];
/** Install intervals of ESP-blocking apps (positive-evidence join against the latest esp_config_detected lists), top 20 by duration. BlockingAppCount carries the uncapped count of matched blocking apps (including those without a measurable interval, e.g. unobserved start). */
blockingApps: BlockingAppInterval[];
/** Uncapped count of apps matched against the blocking set (see BlockingApps). */
@@ -4904,7 +4927,7 @@ export interface TenantConfiguration {
unrestrictedModeEnabled: boolean;
/** When effective, agent guardrails are relaxed: all registry paths, WMI queries, and commands are allowed via GatherRules. File paths and diagnostics paths are allowed except C:\Users. Default: false. Can only be toggled by tenant admins when UnrestrictedModeEnabled is true. EFFECTIVE only while the tenant's edition is Pro (read-time re-gate, fail-closed on trial expiry/downgrade) — see TenantEntitlementService.IsUnrestrictedModeActive. */
unrestrictedMode: boolean;
/** When enabled, tenant member roles (Admin / Operator) may also be granted via Entra ID app-role assignments on the application's Enterprise App (the "roles" claim in the user's token), in addition to the TenantAdmins table. Resolution is table-first: an explicit TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator CanManageBootstrapTokens). Only Admin and Operator are claim-mappable; the platform-wide GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in. Backend-only setting: not delivered to the agent (no ConfigVersion impact). */
/** When enabled, tenant member roles (Admin / Operator / Viewer) may also be granted via Entra ID app-role assignments on the application's Enterprise App (the "roles" claim in the user's token), in addition to the TenantAdmins table. Resolution is table-first: an explicit TenantAdmins entry always overrides a claim-derived role (e.g. to grant an Operator CanManageBootstrapTokens). The platform-wide GlobalAdmin role is never derived from claims. Off by default — per-tenant opt-in. Backend-only setting: not delivered to the agent (no ConfigVersion impact). */
entraAppRolesEnabled: boolean;
/** JSON-serialized list of tenant-specific additional log paths/wildcards to include in the diagnostics ZIP package (additive to global paths). Each entry: { "path": "...", "description": "...", "isBuiltIn": false } */
diagnosticsLogPathsJson: string;