- Tenant Admins add the Push channel; Telegram and e-mail stay Global-Admin-only
- One Push channel per scope on the tenant and ops paths; the dispatcher fans Push out once per send
- The channel also gates session-watch completions and the device test; paired devices stay listed while it is off
- Members whose role comes from an Entra app role pair and receive like table members; a paused device resumes at the owner's next sign-in
- SLA and failure-streak alerts name the tenant; the id moves to a Tenant ID fact
- The legacy single webhook accepts URL webhook providers only
- Portal: Conditional Access hint in the channel editor, neutral role texts
- Landing: push section with the Notify me when done animation and the hero announcement; SignalR is "live updates"
- Privacy, about and FAQ describe push notifications
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured in production with 300 ms latency, router.prefetch loaded only the route tree while auth/me was in flight, and the replace still fetched the route payload after auth/me: no gain on the critical path, one to three extra requests per sign-in return.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- MSAL's in-flight redirect state stays in its tab. storeAuthStateInCookie mirrored it into origin-wide cookies that MSAL reads first, so two tabs signing in at once overwrote each other and the first one failed with state_mismatch, stuck on the landing. A guard test pins the cache config.
- AuthGate keeps the auth-pending overlay for as long as the browser is signed in, so it lifts only once the target route has committed; it used to lift when loading ended and exposed the landing for the whole client navigation. The target's route payload is prefetched while auth/me is in flight.
- A sign-in return that carried a code but ends without an account starts one fresh sign-in from "/", keeping the saved deep link; any further failure in the tab hands over to ProtectedRoute's sign-in card on the deep link, which waits for a click. The auth response is read at module load (the router writes its hydration-time URL back), the tab's temporary MSAL keys are purged before a retry and before the card's sign-in, and loading holds while a redirect leaves the page. New events: auth_redirect_recovered, auth_redirect_recovery_exhausted, auth_redirect_failed (error codes only).
- host.json adds Access-Control-Max-Age: 7200 through the http customHeaders. The Functions host answers every preflight itself and its CORS policy carries no max-age; its custom-headers middleware runs outside the CORS middleware, so the header lands on the preflight too. A guard test pins the entry to exactly that header. The CORS startup log goes: it read settings this host does not use, at a level that never reached the telemetry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The section reads the tenant configurations from config/all and derives everything in the browser: no endpoint, no counter, no backfill.
- Customers: new tenants per UTC day (per Monday-anchored week beyond 90 days), the window's best day, weekly average, the last 7 days, the total and the cumulative gain; 7/30/90/365 days in ?days=; clicking a column lists that bucket's tenants with their edition. A signup is a configuration row with a domain. The section is the first Metrics entry and the target of /admin/metrics.
- Charts: AppBarChart takes a category formatter, a custom tooltip, a column click and whole-number ticks; AppLineChart can hide its legend and use whole-number ticks. Existing callers get the same props as before.
- The Monday-of-week helper moves from the Fleet Health timeline into lib/timeWindow; the Stat tile duplicated in two metrics sections becomes one component, and the duplicate baseline loses that entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
None of them had a production caller. DelegatedAdminService.GetAssignmentsForUpnAsync and GetAssigneesForTenantAsync, DelegatedAdminService.SetStatusAsync with IAdminRepository.SetDelegatedAdminStatusAsync (only a test called it; the operator revoke is RemoveAsync), and IAdminRepository.GetDelegatedAdminAsync and IsMcpUserAsync, which only had their own implementation.
Revoke_InvalidatesCache now pins the revoke the operator route runs (RemoveAsync); Disable_InvalidatesCache covers SetEnabledAsync.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removing or disabling a member under Access Management left their assignment to the tenant's self-service group in place, and the delegated scope never re-checked the membership, so the person kept reading every managed customer.
- Scope: an assignment to msp-{owner} confers the group's tenants only while the assignee holds an enabled TenantAdmins row in the owner (inside the cached row resolution, fail-closed, Warning when ignored). Operator groups and direct grants are unchanged.
- Access Management: remove and disable end the self-service assignment (DelegatedGroupAccess DELETE under every managed tenant, reason member-removed / member-disabled) before the existing stream cut. Re-enabling restores nothing.
- Assigning requires an enabled member and re-reads the membership after the write; the Global Admin group route follows the same rule for a self-service group (enabled member of the owner, bound to it, read-only).
- The SignalR group join resolves the delegated scope uncached, so the re-join right after a revoke cannot be admitted by another instance's warm cache.
- Web: Delegated Access offers only enabled members and says that removing or disabling a member ends their access; the Global Admin page hides the role select for self-service groups.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ErrorCodeCatalog.TryLookupDetailed normalised any 0x-prefixed value without
checking its characters and then ran int.Parse on the low word, so a value
such as 0x8007zzzz threw FormatException. The backend enricher runs that
lookup over device-authored event data on every GET /api/sessions/{id}/events,
so one such event answered HTTP 500 for every reader of the session timeline.
- The catalog parses the hex body once with uint.TryParse (hex digits only)
and derives the HRESULT_FROM_WIN32 low word from the number; it now returns
null for any input instead of throwing.
- ErrorCodeEnricher.EnrichEvents isolates each event: a failed lookup leaves
that event unenriched, logs a warning and the page still returns 200.
- Web errorCodeMap.ts gets the same hex-only check: parseInt read
0x8007643z as 1603 and labelled the garbage ERROR_INSTALL_FAILURE.
MCP already validated; a test pins it.
Tests: the reported shapes return null, an exhaustive sweep of short tails
after every prefix never throws, and the enricher keeps the rest of the page
enriched when one event fails. Against the previous catalog 10 of the new
tests fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five platform reads take ?tenantId= only as a filter, but their QueryParam
scoping also admitted a delegated (MSP) reader of the named tenant:
global/session-reports, global/rule-submissions, global/metrics/mcp-usage,
global/metrics/mcp-usage/daily and global/metrics/verdict-calibration. They
carry operator data (admin notes, contact data and reviews, an organization's
accounts, classifier diagnostics), so they are catalogued None now; the
handlers still read ?tenantId= themselves, and Global Admins and Global
Readers keep the filter.
- A catalog ratchet in PolicyEnforcementMiddlewareTests lists every platform
route a delegated caller reaches; a new one fails the build until it is
added on purpose. The TenantScoping.QueryParam doc states the rule.
- The middleware took the cross-tenant decision on the first ?tenantId=
value while the handlers read all values comma-joined. It now decides on
the joined string, with ASP.NET Core's parser (the handlers parse Url.Query,
where System.Uri has already escaped a stray '%').
- The rule pages show a delegated reader the admin-only hint instead of the
submission list, as they already did for an Operator or Viewer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rating from the feedback bubble reached only the direct Telegram ping: it was the one customer
submission without an ops event and a bell. It now raises FeedbackRatingReceived (Tenant, Info) and a
Global Admin bell, so an alert rule can route it to the Push channel. Message and details carry the
stars and the comment length, never the comment or the sender; the Telegram ping stays until it is
retired. The answer route admits members only (MemberRead, like feedback/general): it now feeds an
operator alert, and the bubble only ever asks members.
The alert rules page was linked from no menu. Ops > Alert Rules points at it, and a search in its
header narrows the rules by event type and category; it filters the display only, so a save keeps
every rule. navSectionReachability.test.ts holds the sidebar and the section registries together in
both directions. The ops events page and the rules search share one SearchInput.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The device model table on the app detail page put the terminal-only failure
rate (failed / (failed + succeeded)) next to an Installs count that also held
in-progress, incomplete and skipped rows, so 11 failed of 28 read as 50%
without a way to check it.
- Version and device-model rows carry Succeeded on the wire.
- The device model table shows Finished (the rate's base) with a muted
"+N not counted"; the Total card names the same rows.
- The PDF report gets Finished and Not counted columns in both tables and
the hint on Total installs.
- The device-model sample floor counts exactly the rate's base; skipped rows
no longer count toward it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A portal link with ?whats-new= opened without a session flashed the panel,
went through the sign-in, and came back without it: MSAL lands on the
origin root and AuthGate reaches the saved link by a client-side
navigation, while WhatsNewPanelHost (root layout, stays mounted) read the
parameter only once on mount. It now follows the search parameter and
opens once per arrival; a protected page waits for the session, so the
panel no longer flashes before the redirect or counts the open twice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Usage Metrics (tenant page and the platform section) shows the window's
sessions in three groups that each sum to the session count:
Autopilot v1 or Device Preparation, Entra or hybrid joined, and the
Autopilot deployment mode (user-driven, self-deploying, pre-provisioned).
Type and mode follow the fleet enrollment class; user-driven no longer
counts the IsUserDriven flag, which every registration sets. The counts
come from the session rows on each request, so every retained session is
covered without a counter.
- DeploymentTypeMetrics gains the new counts and shares; the usage
projection reads EnrollmentType, IsSelfDeployingProfile, IsHybridJoin
- platform and tenant compute share one builder per block
- Platform Usage takes ?days= like the other analytics pages
- the usage pages share their blocks (components/UsageMetricsBlocks.tsx);
enrollment class labels and the time-attribution segments each live in
one place, the duplication baseline shrinks by seven clones
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A push carries only category, event and severity for an ops alert, so the
portal link is the way to the details, and ops alerts had none: the event
id was minted inside the save and never reached the dispatch.
Backend: SaveOpsEventAsync sets OpsEventEntry.Id (OpsEventId,
{Category}_{RowKey}); WriteAsync hands it to the dispatch, and BuildAlert
adds "Open in the portal" -> /admin/ops/?eventId=<id> on the plain and the
payload path, so every ops channel (push, Telegram, Teams, Slack, Discord,
e-mail, JSON) gets it. GET global/ops-events?id= is a point read (400 for a
malformed id, an empty list for a deleted one). hardware_rejected links to
the hardware whitelist (one path constant, also for the bell); the session
watch link names the session's tenant; TenantSignup's portalPath keeps its
tenant query. Only a session link makes a push session-bound: tag and Topic
no longer key on any "id=" in the link.
Web: /admin/ops?eventId= opens that event's detail from the point read
(matched by id), and the detail links to the tenant and to the event's
related page (same-origin paths only) besides the session, which now
carries the tenant. CopyButton and the cursor pager footer are shared
components instead of four and three copies; the duplicate baseline shrinks
by five entries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the event limit or the platform operator blocks a session's device, the
session now carries a block record: Block* fields on the session and its index
row, a server-authored device_blocked timeline event, an interim analyze run for
that event and, on the auto path, an audit entry. The block list itself stays
platform-only.
- Portal: a card above the failure snapshot says the monitoring was paused
(orange) or ended (red), when, until when and, for the event limit, the
session's event count against the limit. The Blocked pill reads the record for
every role; Global Admin mode still adds the live block list.
- A Kill is device-wide until it expires, so its record carries the end and the
card says new enrollments on the device are not monitored until then.
- Status: the stalled and timeout reasons name the block instead of a silent
agent; the classification is unchanged.
- Rules: ANALYZE-ENRL-006 explains the pause right after it (on_event
device_blocked); ANALYZE-ENRL-002 no longer fires for a blocked session.
- MCP: the coverage block of get_session_summary names the block as a gap.
- The agent-facing block message no longer says "by an administrator".
- BlockCause == event_limit replaces ExcessiveEventsAutoActioned as the
auto-action's idempotency marker.
- Privacy page: the event limit next to rate limiting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>