feat(delegated): scoped-global reader — dashboard + analytics + fleet overview

Make a delegated ("MSP") admin a "scoped Global Reader": the same pages a Global
Admin sees, but every tenant switcher/filter is bounded to the managed allow-list.
Reader path only (readWrite deferred). MCP stays read-only (Phase 6 already covers
delegated reads).

Backend (bounded aggregate — reuses the GA per-tenant fan-out):
- global/sessions + global/stats/sessions: GlobalReadOrAdmin -> GlobalReadOrDelegatedSubset
  (keep QueryParam). A delegated caller WITHOUT ?tenantId= is admitted as a bounded
  aggregate; the handler restricts the cross-tenant fan-out to RequestContext.AllowedTenantIds.
  With ?tenantId=X it drills into one managed tenant (middleware validates X in scope).
- TableStorageService: GetAllSessions{,Page,Stats}Async gain an optional allowedTenantIds
  set; FetchAllSessionsPageInternalAsync swaps its Step-1 tenant source to that subset
  (everything downstream — per-tenant query, inverted-tick merge, cursor — is reused).
- IsDelegatedReadTier += GlobalReadOrDelegatedSubset so the single-tenant drill rescue fires.
- SignalR AddToGroup/RemoveFromGroup: admit a delegated caller to a managed tenant-{id}
  group (in scope); global-admins stays platform-scope-only.

Web:
- useAggregatedAdminScope / useGlobalAdminScope: cross-tenant mode is now on for a delegated
  admin too (isGlobalAdmin = crossTenant; new isDelegatedScope). Tenant list bounded to the
  managed subset; always a concrete tenant (no "All tenants" aggregate on analytics pages).
- TenantScopeSelector hides the aggregate option for delegated; GlobalAdminBanner gains a
  blue "Delegated Admin View" variant (wired on all analytics pages + dashboard).
- Nav: Fleet group exposes Dashboard + Fleet Health + Software + Geographic + SLA + Usage;
  showDashboard now includes delegated.
- Dashboard delegated-ready: crossTenant drives the /global endpoints + filter UI; empty
  filter = bounded aggregate over managed tenants, a selected tenant = drill-in; global-admins
  SignalR join stays real-GA-only; read-only (mutations already GA-gated). `?tenant=` deep-link
  seeds the filter.
- Fleet is now a pure stats card grid; the per-tenant session browser /fleet/[tenantId] is
  removed and cards link to /dashboard?tenant=<id>.

Deferred follow-ups: Audit Log + Geographic session drill-in for delegated (both diverge from
the scope-hook pattern / need tenantId threading).

Tests: backend 2484 green (catalog/middleware contract updated for the new subset tier),
web 481 green, MCP 292 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-26 18:06:25 +02:00
parent 250560ce25
commit 8dd45b20fb
30 changed files with 358 additions and 527 deletions
@@ -20,7 +20,7 @@ public class PlatformMetricsServiceWindowTests
{
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>()))
.ReturnsAsync(EmptyPage());
sessionRepo
.Setup(r => r.GetSessionEventsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
@@ -41,8 +41,8 @@ public class PlatformMetricsServiceWindowTests
int? capturedDays = null;
repo
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
.Callback<string?, int?, int, string?>((_, d, _, _) => capturedDays = d)
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>()))
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?>((_, d, _, _, _) => capturedDays = d)
.ReturnsAsync(EmptyPage());
var result = await service.ComputePlatformMetricsAsync(days, limit: 50);
@@ -66,8 +66,8 @@ public class PlatformMetricsServiceWindowTests
int? capturedPageSize = null;
repo
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
.Callback<string?, int?, int, string?>((_, _, ps, _) => capturedPageSize = ps)
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>()))
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?>((_, _, ps, _, _) => capturedPageSize = ps)
.ReturnsAsync(EmptyPage());
var result = await service.ComputePlatformMetricsAsync(days: days, limit: limit);
@@ -83,8 +83,8 @@ public class PlatformMetricsServiceWindowTests
int? capturedDays = null;
repo
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
.Callback<string?, int?, int, string?>((_, d, _, _) => capturedDays = d)
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>()))
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?>((_, d, _, _, _) => capturedDays = d)
.ReturnsAsync(EmptyPage());
var result = await service.ComputePlatformMetricsAsync(0);
@@ -117,7 +117,7 @@ public class PlatformMetricsServiceWindowTests
var (service, repo) = CreateService();
int callCount = 0;
repo
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>()))
.Callback(() => callCount++)
.ReturnsAsync(EmptyPage());
@@ -427,19 +427,38 @@ public class PolicyEnforcementMiddlewareTests
}
[Fact]
public async Task Delegated_AggregateGlobalEndpoint_NoTenantId_IsForbidden()
public void GlobalSessions_IsBoundedSubsetTier()
{
// The no-tenantId AGGREGATE path of a global endpoint fans out over ALL tenants. Even though
// global/sessions is QueryParam-scoped (Phase 2a), a delegated caller WITHOUT ?tenantId= resolves no
// target ⇒ no delegated grant ⇒ denied. Only the single-tenant (?tenantId=X) path is open to them.
// global/sessions + global/stats/sessions are GlobalReadOrDelegatedSubset (bounded aggregate for
// delegated) yet keep QueryParam scoping (single-tenant drill). config/all is the same tier sans drill.
foreach (var path in new[] { "/api/global/sessions", "/api/global/stats/sessions" })
{
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", path);
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.GlobalReadOrDelegatedSubset, entry!.Policy);
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
}
}
[Fact]
public async Task Delegated_GlobalSessions_NoTenantId_IsBoundedAggregate()
{
// global/sessions WITHOUT ?tenantId= is the BOUNDED aggregate for a delegated ("MSP") caller: the
// subset tier admits them and publishes the managed set on AllowedTenantIds, which the handler uses
// to restrict the cross-tenant fan-out to those tenants. (global/metrics/summary, still on the plain
// GlobalReadOrAdmin tier, stays forbidden without a tenantId — see Delegated_MetricsSummary_* below.)
const string upn = "msp@partner.example";
var h = BuildHarness();
h.AsDelegated(TenantB);
var result = await h.Middleware.DecideAsync("GET", "/api/global/sessions", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
Assert.True(result.Allowed);
var rc = result.Context!;
Assert.True(rc.IsDelegatedReader);
Assert.False(rc.IsGlobalReader);
Assert.NotNull(rc.AllowedTenantIds);
Assert.Contains(TenantB.ToLowerInvariant(), rc.AllowedTenantIds!);
}
[Fact]
@@ -560,8 +579,8 @@ public class PolicyEnforcementMiddlewareTests
// reachable by a delegated admin. None == aggregate-only == GA/Reader-only. Pins the leak-critical split.
[Theory]
// SAFE — opened to delegated single-tenant access:
[InlineData("GET", "/api/global/sessions", TenantScoping.QueryParam)]
// SAFE — opened to delegated single-tenant access. (global/sessions + global/stats/sessions moved to
// the GlobalReadOrDelegatedSubset tier — see GlobalSessions_IsBoundedSubsetTier below.)
[InlineData("GET", "/api/global/audit/logs", TenantScoping.QueryParam)]
[InlineData("GET", "/api/global/metrics/app", TenantScoping.QueryParam)]
[InlineData("GET", "/api/global/metrics/fleet-health", TenantScoping.QueryParam)]
@@ -39,20 +39,20 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<SessionSummary>> GetSessionsAsync(string tenantId, int? days = null)
=> _storage.GetSessionsAsync(tenantId, days);
public Task<List<SessionSummary>> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null)
=> _storage.GetAllSessionsAsync(tenantIdFilter, days);
public Task<List<SessionSummary>> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null, IReadOnlyCollection<string>? allowedTenantIds = null)
=> _storage.GetAllSessionsAsync(tenantIdFilter, days, allowedTenantIds);
public Task<RawPage<SessionSummary>> GetSessionsPageAsync(string tenantId, int? days, int pageSize, string? continuation)
=> _storage.GetSessionsPageAsync(tenantId, days, pageSize, continuation);
public Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation)
=> _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation);
public Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation, IReadOnlyCollection<string>? allowedTenantIds = null)
=> _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation, allowedTenantIds);
public Task<SessionStats> GetSessionStatsAsync(string tenantId, int days)
=> _storage.GetSessionStatsAsync(tenantId, days);
public Task<SessionStats> GetAllSessionStatsAsync(string? tenantIdFilter, int days)
=> _storage.GetAllSessionStatsAsync(tenantIdFilter, days);
public Task<SessionStats> GetAllSessionStatsAsync(string? tenantIdFilter, int days, IReadOnlyCollection<string>? allowedTenantIds = null)
=> _storage.GetAllSessionStatsAsync(tenantIdFilter, days, allowedTenantIds);
public async Task<bool> UpdateSessionStatusAsync(
string tenantId, string sessionId, SessionStatus status,
@@ -1,5 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
@@ -11,11 +12,14 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
public class SignalRAddToGroupFunction
{
private readonly ILogger<SignalRAddToGroupFunction> _logger;
private readonly DelegatedAdminService _delegatedAdminService;
public SignalRAddToGroupFunction(
ILogger<SignalRAddToGroupFunction> logger)
ILogger<SignalRAddToGroupFunction> logger,
DelegatedAdminService delegatedAdminService)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
}
[Function("AddToGroup")]
@@ -82,18 +86,33 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
// Check if user is allowed to join this tenant's group. Cross-tenant joins require
// platform scope (Global Admin OR read-only Global Reader) — both have cross-tenant
// READ scope, and group membership only RECEIVES live updates. The admin/member
// notification-group checks below still gate by the real tenant role.
// READ scope, and group membership only RECEIVES live updates — OR a delegated ("MSP")
// admin whose managed scope includes the requested tenant (so the delegated dashboard
// gets the same live session/event pushes a GA gets for that tenant). The admin/member
// notification-group checks below still gate by the real tenant role, so a delegated
// caller (no tenant role) receives session/event broadcasts but NOT notification pushes.
if (requestedTenantId != userTenantId)
{
if (!requestCtx.HasGlobalScope)
var allowedCrossTenant = requestCtx.HasGlobalScope;
if (!allowedCrossTenant && !string.IsNullOrEmpty(userEmail))
{
// realtime/groups/join is not a tenant-scoped route, so the middleware did not
// resolve the delegated scope onto RequestContext — resolve it here, bounded to
// the requested tenant only.
var scope = await _delegatedAdminService.GetScopeAsync(userEmail);
allowedCrossTenant = scope.RoleFor(requestedTenantId) != null;
if (allowedCrossTenant)
_logger.LogInformation($"Delegated user {userEmail} joining managed cross-tenant group: {request.GroupName}");
}
if (!allowedCrossTenant)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join group for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: You can only join groups for your own tenant" });
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
}
else
else if (requestCtx.HasGlobalScope)
{
_logger.LogInformation($"Platform-scope user {userEmail} (role={requestCtx.UserRole}) joining cross-tenant group: {request.GroupName}");
}
@@ -1,5 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
@@ -11,11 +12,14 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
public class SignalRRemoveFromGroupFunction
{
private readonly ILogger<SignalRRemoveFromGroupFunction> _logger;
private readonly DelegatedAdminService _delegatedAdminService;
public SignalRRemoveFromGroupFunction(
ILogger<SignalRRemoveFromGroupFunction> logger)
ILogger<SignalRRemoveFromGroupFunction> logger,
DelegatedAdminService delegatedAdminService)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
}
[Function("RemoveFromGroup")]
@@ -79,10 +83,18 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
}
// Check if user is allowed to leave this tenant's group — platform scope (GA or
// read-only Global Reader) may leave any tenant's group, symmetric with AddToGroup.
// read-only Global Reader) may leave any tenant's group, symmetric with AddToGroup; a
// delegated ("MSP") admin may leave a managed tenant's group (symmetric with the join).
if (requestedTenantId != userTenantId)
{
if (!requestCtx.HasGlobalScope)
var allowedCrossTenant = requestCtx.HasGlobalScope;
if (!allowedCrossTenant && !string.IsNullOrEmpty(userEmail))
{
var scope = await _delegatedAdminService.GetScopeAsync(userEmail);
allowedCrossTenant = scope.RoleFor(requestedTenantId) != null;
}
if (!allowedCrossTenant)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to leave group for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -91,7 +103,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
}
else
{
_logger.LogInformation($"Platform-scope user {userEmail} (role={requestCtx.UserRole}) leaving cross-tenant group: {request.GroupName}");
_logger.LogInformation($"User {userEmail} (role={requestCtx.UserRole}) leaving cross-tenant group: {request.GroupName}");
}
}
@@ -56,11 +56,16 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
tenantIdFilter = tenantIdFilterRaw;
}
_logger.LogInformation(
"Computing cross-tenant session stats (caller={Caller}, filter={Filter}, days={Days})",
callerTenantId, tenantIdFilter ?? "none", days);
// Delegated ("MSP") callers carry AllowedTenantIds (subset tier) → the no-filter aggregate is
// bounded to their managed tenants. Null for GA/Reader = all tenants; ignored when a single
// ?tenantId= is named (middleware already validated it is in the caller's scope).
var allowedTenantIds = req.GetRequestContext().AllowedTenantIds;
var stats = await _sessionRepo.GetAllSessionStatsAsync(tenantIdFilter, days);
_logger.LogInformation(
"Computing cross-tenant session stats (caller={Caller}, filter={Filter}, days={Days}, bounded={Bounded})",
callerTenantId, tenantIdFilter ?? "none", days, allowedTenantIds != null);
var stats = await _sessionRepo.GetAllSessionStatsAsync(tenantIdFilter, days, allowedTenantIds);
return await req.OkAsync(new { success = true, stats });
}
catch (Exception ex)
@@ -31,11 +31,17 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
try
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
// Authentication + authorization enforced by PolicyEnforcementMiddleware (GlobalReadOrDelegatedSubset).
var userEmail = TenantHelper.GetUserIdentifier(req);
var callerTenantId = TenantHelper.GetTenantId(req);
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
// Delegated ("MSP") callers are admitted by the subset tier with AllowedTenantIds set; the
// aggregate (no ?tenantId=) is then bounded to that managed subset. Null for GA/Reader = all
// tenants. When a single ?tenantId= IS named, the repo takes the single-tenant path and this
// bound is irrelevant (the middleware already validated the target is in the caller's scope).
var allowedTenantIds = req.GetRequestContext().AllowedTenantIds;
var parsed = SessionListPagination.ParseQuery(query, acceptFilterTenantId: true);
if (parsed.Error != null)
{
@@ -76,7 +82,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
}
var page = await _sessionRepo.GetAllSessionsPageAsync(
parsed.FilterTenantId, parsed.Days, parsed.PageSize, azureToken);
parsed.FilterTenantId, parsed.Days, parsed.PageSize, azureToken, allowedTenantIds);
string? nextLink = null;
if (!string.IsNullOrEmpty(page.NextRawToken))
@@ -263,7 +263,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
private static bool IsDelegatedReadTier(EndpointPolicy policy)
=> policy is EndpointPolicy.MemberRead
or EndpointPolicy.TenantAdminOrGlobalReader
or EndpointPolicy.GlobalReadOrAdmin;
or EndpointPolicy.GlobalReadOrAdmin
// Subset-tier routes (global/sessions, global/stats/sessions) admit a delegated caller for the
// bounded AGGREGATE (no tenantId, handler bounds to AllowedTenantIds); the single-tenant ?tenantId=
// DRILL still flows through the scoped-route cross-tenant guard, which requires this tier to count
// as a delegated read tier so RoleFor(target) can rescue the cross-tenant block.
or EndpointPolicy.GlobalReadOrDelegatedSubset;
private async Task<CatalogDecisionResult> EvaluateCatalogPolicyAsync(
ClaimsPrincipal? principal, EndpointPolicyEntry entry)
@@ -343,8 +343,12 @@ public static class EndpointAccessPolicyCatalog
new("DELETE", "preview/whitelist/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "preview/notification-email/{tenantId}", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.RouteParam),
new("POST", "preview/send-welcome-email/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "global/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/stats/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
// Subset tier (like config/all): a delegated ("MSP") caller is admitted WITHOUT a named tenantId and
// the handler bounds the aggregate to RequestContext.AllowedTenantIds (its managed subset). With a
// named ?tenantId= the QueryParam path drills into that single tenant (middleware validates it is in
// the caller's scope). GA/Reader keep full cross-tenant access (unbounded aggregate + any drill).
new("GET", "global/sessions", EndpointPolicy.GlobalReadOrDelegatedSubset, TenantScoping.QueryParam),
new("GET", "global/stats/sessions", EndpointPolicy.GlobalReadOrDelegatedSubset, TenantScoping.QueryParam),
new("GET", "global/audit/logs", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/presence", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/tenants-with-deletion-manifests", EndpointPolicy.GlobalReadOrAdmin),
@@ -897,7 +897,8 @@ namespace AutopilotMonitor.Functions.Services
/// <paramref name="tenantIdFilter"/> optionally restricts to one tenant.
/// </summary>
public async Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(
string? tenantIdFilter, int? days, int pageSize, string? continuation)
string? tenantIdFilter, int? days, int pageSize, string? continuation,
IReadOnlyCollection<string>? allowedTenantIds = null)
{
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
@@ -908,7 +909,7 @@ namespace AutopilotMonitor.Functions.Services
return await GetSessionsPageAsync(tenantIdFilter!, days, pageSize, continuation);
}
var page = await FetchAllSessionsPageInternalAsync(maxResults: pageSize, cursor: continuation, days: days);
var page = await FetchAllSessionsPageInternalAsync(maxResults: pageSize, cursor: continuation, days: days, allowedTenantIds: allowedTenantIds);
return new RawPage<SessionSummary>(page.Sessions, page.HasMore ? page.NextCursor : null);
}
@@ -917,7 +918,7 @@ namespace AutopilotMonitor.Functions.Services
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant
/// (routed through the per-tenant index for efficiency).
/// </summary>
public async Task<List<SessionSummary>> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null)
public async Task<List<SessionSummary>> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null, IReadOnlyCollection<string>? allowedTenantIds = null)
{
if (!string.IsNullOrEmpty(tenantIdFilter))
{
@@ -928,7 +929,7 @@ namespace AutopilotMonitor.Functions.Services
string? token = null;
do
{
var page = await GetAllSessionsPageAsync(tenantIdFilter: null, days, pageSize: 1000, continuation: token);
var page = await GetAllSessionsPageAsync(tenantIdFilter: null, days, pageSize: 1000, continuation: token, allowedTenantIds: allowedTenantIds);
all.AddRange(page.Items);
token = page.NextRawToken;
} while (!string.IsNullOrEmpty(token));
@@ -953,11 +954,11 @@ namespace AutopilotMonitor.Functions.Services
/// Cross-tenant variant. Routes through the per-tenant index when
/// <paramref name="tenantIdFilter"/> is set (cheaper scan).
/// </summary>
public async Task<SessionStats> GetAllSessionStatsAsync(string? tenantIdFilter, int days)
public async Task<SessionStats> GetAllSessionStatsAsync(string? tenantIdFilter, int days, IReadOnlyCollection<string>? allowedTenantIds = null)
{
if (days < 1) throw new ArgumentOutOfRangeException(nameof(days));
var sessions = await GetAllSessionsAsync(tenantIdFilter, days);
var sessions = await GetAllSessionsAsync(tenantIdFilter, days, allowedTenantIds);
return AggregateSessionStats(sessions, days);
}
@@ -1073,7 +1074,7 @@ namespace AutopilotMonitor.Functions.Services
/// <see cref="GetAllSessionsPageAsync"/> (single page).
/// </summary>
private async Task<(List<SessionSummary> Sessions, bool HasMore, string? NextCursor)> FetchAllSessionsPageInternalAsync(
int maxResults, string? cursor, int? days)
int maxResults, string? cursor, int? days, IReadOnlyCollection<string>? allowedTenantIds = null)
{
try
{
@@ -1088,6 +1089,16 @@ namespace AutopilotMonitor.Functions.Services
tenantIds.Add(entity.PartitionKey);
}
// Delegated ("MSP") bound: restrict the cross-tenant fan-out to the caller's managed subset.
// This is the SAME per-tenant loop the Global Admin aggregate uses — only the tenant set
// shrinks, so the merge/cursor/pagination below stay identical. Comparison is case-insensitive
// because AllowedTenantIds is lowercased while the config PartitionKey casing is not guaranteed.
if (allowedTenantIds != null)
{
var allowed = new HashSet<string>(allowedTenantIds, StringComparer.OrdinalIgnoreCase);
tenantIds = tenantIds.Where(t => allowed.Contains(t)).ToList();
}
if (tenantIds.Count == 0 && string.IsNullOrEmpty(cursor))
{
var fallback = await FetchAllSessionsFromPrimaryTableInternalAsync(maxResults, days);
@@ -29,8 +29,11 @@ namespace AutopilotMonitor.Shared.DataAccess
/// <summary>
/// Cross-tenant variant of <see cref="GetSessionsAsync"/> (Global Admin).
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant.
/// <paramref name="allowedTenantIds"/> (when non-null) bounds the cross-tenant fan-out to that
/// subset — used by delegated ("MSP") callers so the aggregate covers only their managed tenants.
/// </summary>
Task<List<SessionSummary>> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null);
Task<List<SessionSummary>> GetAllSessionsAsync(
string? tenantIdFilter = null, int? days = null, IReadOnlyCollection<string>? allowedTenantIds = null);
/// <summary>
/// Reads a single page of sessions for <paramref name="tenantId"/>, newest-first
@@ -45,9 +48,12 @@ namespace AutopilotMonitor.Shared.DataAccess
/// <summary>
/// Cross-tenant variant of <see cref="GetSessionsPageAsync"/> (Global Admin).
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant.
/// <paramref name="allowedTenantIds"/> (when non-null) bounds the cross-tenant fan-out to that
/// subset — used by delegated ("MSP") callers so the aggregate covers only their managed tenants.
/// </summary>
Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(
string? tenantIdFilter, int? days, int pageSize, string? continuation);
string? tenantIdFilter, int? days, int pageSize, string? continuation,
IReadOnlyCollection<string>? allowedTenantIds = null);
/// <summary>
/// Server-side aggregation for the dashboard stats cards (per-tenant scope).
@@ -60,8 +66,11 @@ namespace AutopilotMonitor.Shared.DataAccess
/// Cross-tenant variant of <see cref="GetSessionStatsAsync"/> (Global Admin).
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant
/// (routes through the per-tenant index for cheaper scan).
/// <paramref name="allowedTenantIds"/> (when non-null) bounds the aggregate to that subset
/// — used by delegated ("MSP") callers so the cards cover only their managed tenants.
/// </summary>
Task<SessionStats> GetAllSessionStatsAsync(string? tenantIdFilter, int days);
Task<SessionStats> GetAllSessionStatsAsync(
string? tenantIdFilter, int days, IReadOnlyCollection<string>? allowedTenantIds = null);
// --- Session Updates ---
Task<bool> UpdateSessionStatusAsync(
@@ -407,7 +407,7 @@ export default function AnalyzeRulesPage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<GlobalAdminBanner show={scope.isGlobalAdmin} />
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} />
{/* Header */}
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
@@ -361,7 +361,7 @@ export default function AppDetailPage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<GlobalAdminBanner show={scope.isGlobalAdmin} subtitle={globalAdminSubtitle(scope)} />
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} subtitle={globalAdminSubtitle(scope)} />
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<button
@@ -36,7 +36,7 @@ function SoftwareHub() {
return (
<div className="min-h-screen bg-gray-50">
<GlobalAdminBanner show={scope.isGlobalAdmin} subtitle={globalAdminSubtitle(scope)} />
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} subtitle={globalAdminSubtitle(scope)} />
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto pt-6 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
@@ -23,6 +23,7 @@ interface User {
isTenantAdmin?: boolean;
isGlobalAdmin?: boolean;
isGlobalReader?: boolean;
isDelegated?: boolean;
role?: string | null;
}
@@ -39,7 +40,17 @@ interface SignalRApi {
interface UseDashboardSessionsParams {
user: User | null | undefined;
tenantId: string | null | undefined;
/**
* Cross-tenant mode: drives the `/global/sessions` endpoint choice + cross-tenant event acceptance.
* True for a real GA in GA mode AND for a delegated ("MSP") admin (whose aggregate the backend bounds
* to the managed subset). Named globalAdminMode for back-compat; see {@link joinGlobalAdmins}.
*/
globalAdminMode: boolean;
/**
* Whether to join the cross-tenant `global-admins` SignalR broadcast group. Real GA only — a delegated
* caller has no platform scope and would be rejected (403); they rely on the per-tenant reconnect refetch.
*/
joinGlobalAdmins: boolean;
tenantIdFilter: string;
adminMode: boolean;
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>;
@@ -73,6 +84,7 @@ export function useDashboardSessions({
user,
tenantId,
globalAdminMode,
joinGlobalAdmins,
tenantIdFilter,
adminMode,
getAccessToken,
@@ -81,6 +93,8 @@ export function useDashboardSessions({
signalR,
}: UseDashboardSessionsParams): UseDashboardSessionsReturn {
const { on, off, isConnected, joinGroup, leaveGroup } = signalR;
const joinGlobalAdminsRef = useRef(joinGlobalAdmins);
joinGlobalAdminsRef.current = joinGlobalAdmins;
const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(true);
@@ -351,7 +365,7 @@ export function useDashboardSessions({
// Initial fetch — gated on user role + tenant readiness
useEffect(() => {
if (user && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && user.role !== "Operator") {
if (user && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && !user.isDelegated && user.role !== "Operator") {
return; // regular users are redirected elsewhere; don't fetch
}
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
@@ -392,26 +406,28 @@ export function useDashboardSessions({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected, tenantId]);
// Join/leave global-admins group when Global Admin mode changes
// Join/leave the cross-tenant global-admins broadcast group. Real GA only (joinGlobalAdmins) — a
// delegated ("MSP") caller has no platform scope and would be rejected (403); the dashboard still reads
// the bounded aggregate and recovers live state via the reconnect refetch above.
useEffect(() => {
if (!isConnected) return;
if (globalAdminMode) {
console.log("[Dashboard] Global Admin mode enabled: joining global-admins group");
if (joinGlobalAdmins) {
console.log("[Dashboard] joining global-admins group");
joinGroup("global-admins");
} else {
console.log("[Dashboard] Global Admin mode disabled: leaving global-admins group");
console.log("[Dashboard] leaving global-admins group");
leaveGroup("global-admins");
}
return () => {
if (globalAdminMode) {
if (joinGlobalAdmins) {
console.log("[Dashboard] Component unmounting: leaving global-admins group");
leaveGroup("global-admins");
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected, globalAdminMode]);
}, [isConnected, joinGlobalAdmins]);
// SignalR listeners — re-register when connection cycles
useEffect(() => {
@@ -1,9 +1,10 @@
"use client";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { GlobalAdminBanner } from "@/components/GlobalAdminBanner";
import { useSignalR } from "../../contexts/SignalRContext";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
@@ -79,29 +80,40 @@ function HomeContent() {
const mainClassName = fullWidth
? "w-full px-4 sm:px-6 lg:px-8 py-4"
: "max-w-7xl mx-auto py-4 sm:px-6 lg:px-8";
const { user, logout, getAccessToken, isPreviewBlocked } = useAuth();
const { user, logout, getAccessToken, isPreviewBlocked, hasGlobalScope } = useAuth();
const { addNotification } = useNotifications();
const [apiStatus, setApiStatus] = useState<"unchecked" | "checking" | "healthy" | "error">("unchecked");
const [tenantIdFilter, setTenantIdFilter] = useState("");
// `?tenant=<id>` deep-links a cross-tenant view onto one tenant — used by the /fleet card grid to drill
// a managed tenant into this dashboard. Ignored for non-cross-tenant users (the filter is unused there).
const initialTenantFilter = searchParams?.get("tenant") ?? "";
const [tenantIdFilter, setTenantIdFilter] = useState(initialTenantFilter);
// Mirrors the last filter value the user actually submitted (Submit / Clear).
// Drives the stats refetch — server-side stats follow the submitted scope so
// typing into the filter input doesn't trigger a backend round-trip per keystroke.
const [submittedTenantIdFilter, setSubmittedTenantIdFilter] = useState("");
const [submittedTenantIdFilter, setSubmittedTenantIdFilter] = useState(initialTenantFilter);
const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode();
const signalR = useSignalR();
const { tenantId } = useTenant();
// A delegated ("MSP") admin browses cross-tenant bounded to their managed subset. Cross-tenant mode drives
// the /global endpoints + tenant filter UI for a real GA in GA mode OR a delegated admin; an empty filter
// is the bounded aggregate (backend restricts it to the managed tenants). The global-admins SignalR
// broadcast group stays real-GA-only — a delegated caller has no platform scope and would be rejected.
const isDelegated = user?.isDelegated ?? false;
const crossTenant = (globalAdminMode && hasGlobalScope) || isDelegated;
const joinGlobalAdmins = globalAdminMode && hasGlobalScope;
const {
showBlockConfirm, sessionToBlock, blockingDevice, blockedDevicesSet, setBlockedDevicesSet,
blockDevice, confirmBlock, cancelBlock,
} = useBlockDevice(getAccessToken, addNotification, adminMode, globalAdminMode);
} = useBlockDevice(getAccessToken, addNotification, adminMode, crossTenant);
const {
sessions, loading, hasMore, loadingMore,
refetch, refetchWith, loadMore, loadAll, removeSession,
} = useDashboardSessions({
user, tenantId, globalAdminMode, tenantIdFilter, adminMode,
user, tenantId, globalAdminMode: crossTenant, joinGlobalAdmins, tenantIdFilter, adminMode,
getAccessToken, addNotification, setBlockedDevicesSet, signalR,
});
@@ -123,7 +135,7 @@ function HomeContent() {
sessions,
blockedDevicesSet,
tenantId,
globalAdminMode,
globalAdminMode: crossTenant,
tenantIdFilter,
hasMore,
loadingMore,
@@ -133,10 +145,10 @@ function HomeContent() {
// Stats cards: server-side aggregation so the numbers don't drift with whatever
// the client has paginated into view. Refreshes on SignalR newSession/newevents
// (debounced) and on SignalR reconnect to recover from any missed messages.
const isRegularUser = !!user && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && user.role !== "Operator";
const isRegularUser = !!user && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && !user.isDelegated && user.role !== "Operator";
const { stats: dashboardStats } = useDashboardStats({
tenantId,
globalAdminMode,
globalAdminMode: crossTenant,
submittedTenantIdFilter,
getAccessToken,
addNotification,
@@ -144,17 +156,24 @@ function HomeContent() {
disabled: isRegularUser,
});
// Redirect users without own-tenant/platform scope away from the session list. A delegated ("MSP") admin
// manages OTHER tenants and belongs on the fleet overview, not the end-user progress portal; everyone else
// without scope goes to /progress. A read-only Global Reader has cross-tenant read scope → stays.
// Redirect users without own-tenant/platform/delegated scope away from the session list to /progress. A
// delegated ("MSP") admin now STAYS on the dashboard (cross-tenant bounded session browser); their /fleet
// card grid is the landing overview but they may browse sessions here. A read-only Global Reader stays too.
useEffect(() => {
if (user && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && user.role !== 'Operator') {
router.replace(user.isDelegated ? "/fleet" : "/progress");
if (user && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && !user.isDelegated && user.role !== 'Operator') {
router.replace("/progress");
}
}, [user, router]);
const serialValidationEnabled = useTenantSecurityConfig(tenantId, user, getAccessToken, addNotification);
const tenantList = useTenantList(globalAdminMode, getAccessToken);
const rawTenantList = useTenantList(crossTenant, getAccessToken);
// Delegated: bound the tenant filter's autocomplete to the managed subset (defense in depth on top of the
// backend-bounded config/all). GA/Reader: the full list.
const tenantList = useMemo(() => {
if (!isDelegated) return rawTenantList;
const allow = new Set((user?.delegatedTenantIds ?? []).map((t) => t.toLowerCase()));
return rawTenantList.filter((t) => allow.has(t.tenantId.toLowerCase()));
}, [rawTenantList, isDelegated, user?.delegatedTenantIds]);
// Disable global-scope mode for users without platform scope. A read-only Global Reader keeps it
// (their cross-tenant view is read-only-safe; writes are gated separately + backend-enforced).
@@ -165,13 +184,15 @@ function HomeContent() {
}
}, [user, globalAdminMode]);
// Clear tenant filter when leaving Global Admin mode (refetch is owned by useDashboardSessions)
// Clear the tenant filter when cross-tenant mode turns off (refetch is owned by useDashboardSessions).
// Keyed on crossTenant (not raw globalAdminMode) so a delegated ("MSP") admin — whose crossTenant is
// always on — keeps any `?tenant=` deep-link / typed filter instead of having it wiped on mount.
useEffect(() => {
if (!globalAdminMode) {
if (!crossTenant) {
setTenantIdFilter("");
setSubmittedTenantIdFilter("");
}
}, [globalAdminMode]);
}, [crossTenant]);
// Auto-load more when the user needs more sessions than currently loaded
// (e.g. increased sessionsPerPage, paginated forward, or applied a sort/column filter
@@ -222,6 +243,13 @@ function HomeContent() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
{/* Delegated ("MSP") admin: blue cross-tenant banner. Empty filter = bounded aggregate over the
managed tenants; a selected tenant = drill-in. (GA gets no banner here, as before.) */}
<GlobalAdminBanner
show={isDelegated}
delegated
subtitle={submittedTenantIdFilter ? "viewing one managed tenant" : "aggregating across your managed tenants"}
/>
{/* Main content */}
<main className={mainClassName}>
<div className="px-4 sm:px-0">
@@ -417,7 +445,7 @@ function HomeContent() {
loadingMore={loadingMore}
onLoadAll={loadAll}
adminMode={adminMode}
globalAdminMode={globalAdminMode}
globalAdminMode={crossTenant}
tenantIdFilter={tenantIdFilter}
onTenantIdFilterChange={applyTenantIdFilter}
onTenantIdFilterSubmit={submitTenantIdFilter}
@@ -173,7 +173,7 @@ export default function FleetHealthPage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<GlobalAdminBanner show={scope.isGlobalAdmin} subtitle={globalAdminSubtitle(scope)} />
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} subtitle={globalAdminSubtitle(scope)} />
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
@@ -1,134 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import { extractContinuation } from "@/lib/paginationLink";
import type { Session } from "@/types/session";
export interface TenantSessionsState {
sessions: Session[];
/** First-page load. */
loading: boolean;
/** A subsequent page (loadMore) is in flight. */
loadingMore: boolean;
/** The server has more rows beyond what's loaded (a continuation token is held). */
hasMore: boolean;
/** Set if a fetch failed (network or non-2xx); the page renders an inline error. */
error: boolean;
/** Fetch and append the next page. No-op while one is in flight or when there's nothing more. */
loadMore: () => void;
}
// Page size for the managed-tenant session list — server-bounded by `days` on top of this.
const PAGE_SIZE = 100;
/**
* Fetches the recent session list for ONE managed tenant via the Phase-2a single-tenant
* `/api/global/sessions?tenantId=` endpoint, with continuation-token pagination (Load more). The backend
* bounds this read to the caller's delegated scope (GlobalReadOrDelegatedSubset); the page also guards the
* tenantId against delegatedTenantIds as defense in depth before mounting this hook.
*
* @param tenantId managed tenant to load (verbatim, as it appears in the route/delegated list).
* @param days lookback window passed to the list endpoint.
*/
export function useTenantSessions(tenantId: string, days: number): TenantSessionsState {
const { getAccessToken } = useAuth();
const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState(false);
const continuationRef = useRef<string | null>(null);
// Generation guard: bumped on every (tenant, days) change so an in-flight loadMore from a previous
// tenant can't append into the new tenant's list after a fast switch.
const genRef = useRef(0);
const loadingMoreRef = useRef(false);
useEffect(() => {
const myGen = ++genRef.current;
continuationRef.current = null;
if (!tenantId) {
setSessions([]);
setLoading(false);
setHasMore(false);
setError(false);
return;
}
// Clear rows on every new tenant fetch — otherwise navigating A→B briefly renders A's sessions under
// B's heading until B's request lands (both in scope, so not a leak, but misleading in the drill-in).
setSessions([]);
setLoading(true);
setHasMore(false);
setError(false);
const run = async () => {
try {
const response = await authenticatedFetch(
api.globalSessions.list(tenantId, days, { pageSize: PAGE_SIZE }),
getAccessToken
);
if (myGen !== genRef.current) return; // a newer tenant/days took over
if (!response.ok) {
setError(true);
setLoading(false);
return;
}
const data = await response.json();
if (myGen !== genRef.current) return;
const cont = extractContinuation(data.nextLink);
continuationRef.current = cont;
setSessions((data.sessions as Session[]) || []);
setHasMore(!!cont);
setLoading(false);
} catch (err) {
console.error(`Error fetching sessions for tenant ${tenantId}:`, err);
if (myGen === genRef.current) {
setError(true);
setLoading(false);
}
}
};
run();
}, [tenantId, days, getAccessToken]);
const loadMore = useCallback(async () => {
const cont = continuationRef.current;
if (!cont || loadingMoreRef.current || !tenantId) return;
const myGen = genRef.current;
loadingMoreRef.current = true;
setLoadingMore(true);
try {
const response = await authenticatedFetch(
api.globalSessions.list(tenantId, days, { pageSize: PAGE_SIZE, continuation: cont }),
getAccessToken
);
if (myGen !== genRef.current) return; // tenant/days switched mid-flight — drop this page
if (!response.ok) {
// Stop paginating on error rather than spamming a broken token; the loaded rows stay.
setHasMore(false);
return;
}
const data = await response.json();
if (myGen !== genRef.current) return;
const next = extractContinuation(data.nextLink);
continuationRef.current = next;
setSessions((prev) => [...prev, ...((data.sessions as Session[]) || [])]);
setHasMore(!!next);
} catch (err) {
console.error(`Error loading more sessions for tenant ${tenantId}:`, err);
if (myGen === genRef.current) setHasMore(false);
} finally {
loadingMoreRef.current = false;
if (myGen === genRef.current) setLoadingMore(false);
}
}, [tenantId, days, getAccessToken]);
return { sessions, loading, loadingMore, hasMore, error, loadMore };
}
@@ -1,233 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { useTenantList } from "@/hooks/useTenantList";
import { SessionTable } from "@/app/dashboard/components/SessionTable";
import { useDashboardFilters } from "@/app/dashboard/hooks/useDashboardFilters";
import { useFleetSummaries } from "../hooks/useFleetSummaries";
import { useTenantSessions } from "./hooks/useTenantSessions";
import type { FleetSummary } from "../lib/fleetRollup";
const DAYS = 30;
const FLEET_FULL_WIDTH_KEY = "fleet_fullWidth";
// Stable empty refs so the read-only SessionTable / filter memos don't churn on every render.
const EMPTY_SET: ReadonlySet<string> = new Set();
const NOOP = () => {};
function ArrowLeftIcon({ className = "h-4 w-4" }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
);
}
/**
* Fleet drill-in — one managed tenant's read-only session browser for a delegated ("MSP") admin.
*
* The tenant is an explicit route param, so every read is a single-tenant `/global/*?tenantId=` call the
* backend bounds to the caller's delegated scope (GlobalReadOrDelegatedSubset). No all-tenants path here; as
* defense in depth we also refuse a tenantId the caller doesn't manage (unless full platform scope) BEFORE
* issuing any fetch.
*
* Reuses the dashboard's session browser (useDashboardFilters + SessionTable) for full parity — search, sort,
* status filter, column selection, page-size, full-width, Next/Prev pagination (which pulls more pages from
* the server as you advance). Rendered READ-ONLY: adminMode/globalAdminMode=false and user=null hide the
* delete/block actions and the cross-tenant filter box; rows open `/sessions/{id}?tenantId=` so the detail
* page loads the managed tenant's session (also read-only). Inspector stays Global-Admin-only.
*/
export default function FleetTenantPage() {
const params = useParams<{ tenantId: string }>();
const tenantId = decodeURIComponent(params.tenantId);
const { user, hasGlobalScope, hasFleetScope } = useAuth();
const allowed = useMemo(() => {
if (hasGlobalScope) return true; // GA / Global Reader may view any tenant
const managed = new Set((user?.delegatedTenantIds ?? []).map((t) => t.toLowerCase()));
return managed.has(tenantId.toLowerCase());
}, [hasGlobalScope, user?.delegatedTenantIds, tenantId]);
// Gate every data fetch on `allowed` — an empty id makes the hooks no-op, so an unmanaged tenant never
// triggers a request even though the backend would reject it anyway.
const effectiveTenantId = allowed ? tenantId : "";
const tenants = useTenantList(hasFleetScope);
const domainName = useMemo(
() => tenants.find((t) => t.tenantId.toLowerCase() === tenantId.toLowerCase())?.domainName || "",
[tenants, tenantId]
);
const { summaries } = useFleetSummaries(effectiveTenantId ? [effectiveTenantId] : [], DAYS);
const summary: FleetSummary | undefined = summaries[effectiveTenantId];
const { sessions, loading, loadingMore, hasMore, error, loadMore } = useTenantSessions(effectiveTenantId, DAYS);
// Full-width toggle (own localStorage key; read post-mount to avoid an SSR hydration mismatch).
const [fullWidth, setFullWidth] = useState(false);
useEffect(() => {
try { setFullWidth(localStorage.getItem(FLEET_FULL_WIDTH_KEY) === "1"); } catch { /* ignore */ }
}, []);
const toggleFullWidth = useCallback(() => {
setFullWidth((prev) => {
const next = !prev;
try { localStorage.setItem(FLEET_FULL_WIDTH_KEY, next ? "1" : "0"); } catch { /* ignore */ }
return next;
});
}, []);
// Reuse the dashboard's filter/sort/pagination engine. tenantId=null + globalAdminMode=false ⇒ no per-tenant
// re-filter (the data is already single-tenant) and the global-only columns / tenant-filter box stay hidden.
const filters = useDashboardFilters({
sessions,
blockedDevicesSet: EMPTY_SET as Set<string>,
tenantId: null,
globalAdminMode: false,
tenantIdFilter: "",
hasMore,
loadingMore,
loadMore,
});
const linkTarget = useCallback(
(sessionId: string) => `/sessions/${sessionId}?tenantId=${encodeURIComponent(tenantId)}`,
[tenantId]
);
const containerClass = fullWidth
? "w-full px-4 sm:px-6 lg:px-8 py-4"
: "mx-auto max-w-7xl p-4 sm:p-6 lg:p-8";
return (
<div className={containerClass}>
<Link
href="/fleet"
className="mb-4 inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<ArrowLeftIcon className="h-4 w-4" />
Back to fleet
</Link>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{domainName || "Managed tenant"}
</h1>
<p className="mt-1 break-all font-mono text-xs text-gray-400 dark:text-gray-500">{tenantId}</p>
</div>
{!allowed ? (
<div className="rounded-lg border border-dashed border-gray-300 p-10 text-center text-gray-500 dark:border-gray-700 dark:text-gray-400">
You don&apos;t manage this tenant.
</div>
) : (
<>
{/* Stat tiles — terminal-only success rate, matching the fleet cards. */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile label="Active sessions" value={summary ? String(summary.activeCount) : "—"} />
<StatTile label={`Sessions · ${DAYS}d`} value={summary ? String(summary.totalLastNDays) : "—"} />
<StatTile
label="Failed"
value={summary ? String(summary.failedLastNDays) : "—"}
tone={!!summary && summary.failedLastNDays > 0 ? "danger" : "default"}
/>
<StatTile
label="Success rate"
value={summary ? `${summary.successRatePct}%` : "—"}
tone={
!!summary &&
successTone(summary.successRatePct, summary.succeededLastNDays + summary.failedLastNDays) === "danger"
? "danger"
: "default"
}
/>
</div>
{/* Session browser — same component the dashboard uses, read-only. */}
{error ? (
<div className="rounded-lg border border-gray-200 bg-white p-10 text-center text-sm text-red-600 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-red-400">
Couldn&apos;t load sessions for this tenant.
</div>
) : loading && sessions.length === 0 ? (
<div className="flex items-center justify-center rounded-lg border border-gray-200 bg-white p-12 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">Loading sessions…</span>
</div>
) : sessions.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 p-10 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
No sessions in the last {DAYS} days.
</div>
) : (
<SessionTable
sessions={filters.effectiveSessions}
filteredSessions={filters.filteredSessions}
sortedSessions={filters.sortedSessions}
paginatedSessions={filters.paginatedSessions}
searchQuery={filters.searchQuery}
onSearchQueryChange={filters.setSearchQuery}
statusFilter={filters.statusFilter}
onStatusFilterChange={filters.setStatusFilter}
sortColumn={filters.sortColumn}
sortDirection={filters.sortDirection}
onSort={filters.handleSort}
currentPage={filters.currentPage}
totalPages={filters.totalPages}
onPreviousPage={filters.handlePreviousPage}
onNextPage={filters.handleNextPage}
sessionsPerPage={filters.sessionsPerPage}
onSessionsPerPageChange={filters.handleSessionsPerPageChange}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadAll={loadMore}
adminMode={false}
globalAdminMode={false}
tenantIdFilter=""
onTenantIdFilterChange={NOOP}
onTenantIdFilterSubmit={NOOP}
onTenantIdFilterClear={NOOP}
tenantList={[]}
blockedDevicesSet={EMPTY_SET as Set<string>}
isPreviewBlocked={false}
user={null}
columnFilters={filters.columnFilters}
onColumnFiltersChange={filters.setColumnFilters}
onDeleteSession={NOOP}
pendingDeletions={EMPTY_SET}
onBlockDevice={NOOP}
fullWidth={fullWidth}
onToggleFullWidth={toggleFullWidth}
sessionLinkTarget={linkTarget}
/>
)}
</>
)}
</div>
);
}
type Tone = "default" | "danger";
/** Red emphasis only once there are TERMINAL sessions to judge (success rate is terminal-only). */
function successTone(pct: number, terminalCount: number): Tone {
if (terminalCount === 0) return "default";
return pct < 80 ? "danger" : "default";
}
function StatTile({ label, value, tone = "default" }: { label: string; value: string; tone?: Tone }) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div className="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
{label}
</div>
<div
className={`mt-1 text-2xl font-semibold ${
tone === "danger" ? "text-red-600 dark:text-red-400" : "text-gray-900 dark:text-white"
}`}
>
{value}
</div>
</div>
);
}
@@ -15,8 +15,8 @@ const DAYS = 30;
* The tenant list comes from config/all (backend-bounded to the caller's subset, Phase 2b) and is
* intersected with the JWT delegatedTenantIds as client-side defense in depth. Per-tenant health is fanned
* out (bounded) over the Phase-2a single-tenant `/global/stats/sessions?tenantId=` endpoint — no all-tenants
* code path. Cards are triage-ordered (most failures first). Drill-in into a managed tenant's dashboards is
* Phase 3d.
* code path. Cards are triage-ordered (most failures first). This is a pure stats overview; a card drills the
* tenant into the dashboard (`/dashboard?tenant=<id>`), the cross-tenant bounded session browser.
*/
export default function FleetPage() {
const { user } = useAuth();
@@ -113,7 +113,7 @@ function RollupTile({ label, value, tone = "default" }: { label: string; value:
function FleetCard({ tenant, summary }: { tenant: TenantInfo; summary?: FleetSummary }) {
return (
<Link
href={`/fleet/${encodeURIComponent(tenant.tenantId)}`}
href={`/dashboard?tenant=${encodeURIComponent(tenant.tenantId)}`}
className="block rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-colors hover:border-blue-400 hover:shadow-md dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-500"
>
<div className="truncate text-base font-semibold text-gray-900 dark:text-white">
@@ -362,7 +362,7 @@ export default function GatherRulesPage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<GlobalAdminBanner show={scope.isGlobalAdmin} />
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} />
{/* Header */}
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
@@ -207,6 +207,7 @@ export default function GeographicPerformancePage() {
<div className="min-h-screen bg-gray-50">
<GlobalAdminBanner
show={scope.isGlobalAdmin}
delegated={scope.isDelegatedScope}
subtitle={globalAdminSubtitle(scope, "aggregating data across all tenants")}
/>
<header className="bg-white shadow">
@@ -182,7 +182,7 @@ export default function SlaPage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<GlobalAdminBanner show={scope.isGlobalAdmin} />
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} />
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
@@ -171,7 +171,7 @@ export default function UsageMetricsPage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<GlobalAdminBanner show={scope.isGlobalAdmin} />
<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} />
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
@@ -29,28 +29,38 @@ export function globalAdminSubtitle(
}
/**
* The "Global Admin View" bar shown at the top of pages while a global admin browses with
* Global-Admin mode enabled. Renders nothing when {@link show} is false.
* The cross-tenant view bar shown at the top of pages while a global admin (purple "Global Admin View")
* or a delegated ("MSP") admin (blue "Delegated Admin View") browses cross-tenant. Renders nothing when
* {@link show} is false.
*
* @param subtitle optional text after the dash. Defaults to the static "access to all tenants"
* (override-only pages); aggregated pages pass {@link globalAdminSubtitle} for dynamic text.
* @param delegated when true, render the delegated ("MSP") variant — blue, "Delegated Admin View" — to
* make clear the cross-tenant scope is bounded to the caller's managed subset (not the whole platform).
*/
export function GlobalAdminBanner({
show,
subtitle = "access to all tenants",
delegated = false,
}: {
show: boolean;
subtitle?: ReactNode;
delegated?: boolean;
}) {
if (!show) return null;
const barClass = delegated
? "bg-blue-700 text-white text-sm px-4 py-2 flex items-center justify-center space-x-2"
: "bg-purple-700 text-white text-sm px-4 py-2 flex items-center justify-center space-x-2";
const subtitleClass = delegated ? "text-blue-200" : "text-purple-300";
return (
<div className="bg-purple-700 text-white text-sm px-4 py-2 flex items-center justify-center space-x-2">
<div className={barClass}>
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">Global Admin View</span>
<span className="text-purple-300">&mdash; {subtitle}</span>
<span className="font-medium">{delegated ? "Delegated Admin View" : "Global Admin View"}</span>
<span className={subtitleClass}>&mdash; {subtitle}</span>
</div>
);
}
@@ -399,10 +399,11 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
// Regular users see minimal nav. A read-only Global Reader has platform scope, and a delegated MSP admin
// has fleet scope → both get the (group-filtered) nav rather than the minimal regular-user list.
const isRegularUser = !isAdminOrOperator && !hasFleetScope;
// The Dashboard (own-tenant session list) is only meaningful for a genuine own-tenant/platform user.
// A delegated-only MSP admin has no own-tenant stake — /dashboard would just bounce them to /fleet —
// so hide the link for them; their entry point is the Fleet group.
const showDashboard = isAdminOrOperator || hasGlobalScope;
// The Dashboard is the cross-tenant session browser. Own-tenant/platform users see their own/all
// sessions; a delegated ("MSP") admin sees an aggregate across their managed tenants (bounded server-side)
// with the tenant filter scoped to that subset — so they get the link too (the /fleet card grid stays
// their landing overview).
const showDashboard = isAdminOrOperator || hasGlobalScope || isDelegated;
const renderNavContent = (isMobile = false) => (
<>
@@ -8,6 +8,8 @@ export interface TenantSelectorScope {
tenants: TenantInfo[];
selectedTenantId: string;
setSelectedTenantId: (id: string) => void;
/** True for a delegated ("MSP") caller — suppresses the "All tenants" aggregate option (never served to delegated). */
isDelegatedScope?: boolean;
}
/**
@@ -49,7 +51,7 @@ export function TenantScopeSelector({
onChange={(e) => scope.setSelectedTenantId(e.target.value)}
className={selectClass}
>
{allowAggregated && <option value="">All tenants (aggregated)</option>}
{allowAggregated && !scope.isDelegatedScope && <option value="">All tenants (aggregated)</option>}
{scope.tenants.map((t) => (
<option key={t.tenantId} value={t.tenantId}>
{t.domainName
@@ -7,66 +7,84 @@ import { useAdminMode } from "@/hooks/useAdminMode";
import { useTenantList, type TenantInfo } from "@/hooks/useTenantList";
export interface AggregatedAdminScope {
/** True when Global-Admin mode is toggled on AND the user actually is a global admin. */
/**
* True when the page should drive the cross-tenant `/global/` endpoints + show the tenant selector:
* a GlobalAdmin/GlobalReader in GA mode, OR a delegated ("MSP") admin (always-on). The name is kept
* for backward compatibility with the consuming pages; use {@link isDelegatedScope} to tell them apart.
*/
isGlobalAdmin: boolean;
/** Sorted tenant list for the selector. Empty unless {@link isGlobalAdmin}. */
/** True when the cross-tenant scope is a delegated ("MSP") subset (not full platform scope). */
isDelegatedScope: boolean;
/** Sorted tenant list for the selector. Empty unless {@link isGlobalAdmin}; bounded to the managed subset for delegated. */
tenants: TenantInfo[];
/** Currently selected tenant; empty string ("") means the aggregated "All tenants" view. */
/** Currently selected tenant; empty string ("") means the aggregated "All tenants" view (GA only — never for delegated). */
selectedTenantId: string;
setSelectedTenantId: (id: string) => void;
/** Friendly domain name of the selected tenant, if known. */
selectedTenantName: string | undefined;
/**
* Tenant to query: for GAs this is {@link selectedTenantId} verbatim (so "" → aggregated);
* for regular users it is their own tenant. Pass `effectiveTenantId || undefined` to the
* `/global/` endpoints to request the cross-tenant aggregate.
* Tenant to query: for cross-tenant callers this is {@link selectedTenantId} verbatim (so "" → aggregated
* for a GA); for regular users it is their own tenant. A delegated caller always resolves to a concrete
* managed tenant. Pass `effectiveTenantId || undefined` to the `/global/` endpoints.
*/
effectiveTenantId: string;
/** GA mode with no tenant selected → aggregated cross-tenant view. */
/** GA mode with no tenant selected → aggregated cross-tenant view. Never true for a delegated caller. */
isAggregatedGlobalView: boolean;
/** GA viewing a specific tenant other than their own. */
/** Cross-tenant caller viewing a specific tenant other than their own. */
isGlobalOverride: boolean;
/**
* True once the initial selection has settled (own tenant, or the URL seed). Gate data fetches on
* this so the page does not fire a wasted request in the wrong scope before the default-selection
* effect runs. (Stays true; selection is re-defaulted on GA-mode transitions, see {@link scopeKey}.)
* True once the initial selection has settled (own tenant / URL seed for a GA, or the first managed
* tenant for a delegated caller). Gate data fetches on this so the page does not fire a wasted request
* in the wrong scope before the default-selection effect runs.
*/
scopeInitialized: boolean;
/**
* Stable identity of the current query scope — changes whenever GA mode is toggled OR the effective
* tenant changes. Page fetch effects MUST depend on this (not just on selectedTenantId) so that
* toggling GA mode while the page is mounted triggers a refetch instead of showing stale data from
* the previous scope.
* toggling GA mode while the page is mounted triggers a refetch instead of showing stale data.
*/
scopeKey: string;
}
/**
* Global-Admin tenant scope for the **aggregated-capable** page variant (fleet-health,
* geographic-performance, apps, apps/[appName]). Unlike the override variant, the selection may
* be empty ("All tenants" aggregated), GAs always hit the `/global/` endpoints (passing
* `selectedTenantId || undefined`), and a one-shot {@link AggregatedAdminScope.scopeInitialized}
* guard defers the initial fetch until the default selection settles.
* geographic-performance, apps, apps/[appName]). A GA/Reader may pick the empty "All tenants" aggregate;
* a delegated ("MSP") admin gets the SAME pages but bounded to its managed subset and ALWAYS on a concrete
* tenant (no all-tenants aggregate — the backend never serves a no-tenantId aggregate to delegated here).
*
* @param opts.urlGlobal seed from `?global=1` — when set, honor the URL's tenant scope on init
* instead of defaulting to the own tenant (used by deep-linked detail pages).
* @param opts.urlTenantId seed from `?tenantId=` — the specific tenant to select ("" = aggregated).
* @param opts.urlGlobal seed from `?global=1` — when set, honor the URL's tenant scope on init (GA only).
* @param opts.urlTenantId seed from `?tenantId=` — the specific tenant to select ("" = aggregated, GA only).
*
* Pair with `<TenantScopeSelector scope={scope} allowAggregated />` and
* `<GlobalAdminBanner show={scope.isGlobalAdmin} subtitle={globalAdminSubtitle(scope)} />`.
* `<GlobalAdminBanner show={scope.isGlobalAdmin} delegated={scope.isDelegatedScope} subtitle={globalAdminSubtitle(scope)} />`.
*/
export function useAggregatedAdminScope(opts?: {
urlGlobal?: boolean;
urlTenantId?: string;
}): AggregatedAdminScope {
const { tenantId } = useTenant();
const { hasGlobalScope } = useAuth();
const { hasGlobalScope, user } = useAuth();
const { globalAdminMode } = useAdminMode();
// Platform scope (GA or read-only Global Reader) — read-only-safe cross-tenant/aggregated view + selector.
const isGlobalAdmin = Boolean(globalAdminMode && hasGlobalScope);
// A delegated ("MSP") admin has cross-tenant read of a SUBSET of tenants but no full platform scope.
// Cross-tenant mode is on for a GA/Reader in GA mode, AND always-on for a delegated admin (they have no
// own-tenant view to toggle from). The field is exposed as isGlobalAdmin for page compatibility.
const isDelegated = user?.isDelegated ?? false;
const isDelegatedScope = Boolean(isDelegated && !hasGlobalScope);
const isGlobalAdmin = Boolean((globalAdminMode && hasGlobalScope) || isDelegatedScope);
const allTenants = useTenantList(isGlobalAdmin);
// Delegated: bound the selector to the managed allow-list (defense in depth on top of the backend-bounded
// config/all). GA/Reader: the full list.
const delegatedAllow = useMemo(
() => new Set((user?.delegatedTenantIds ?? []).map((t) => t.toLowerCase())),
[user?.delegatedTenantIds]
);
const tenants = useMemo(
() => (isDelegatedScope ? allTenants.filter((t) => delegatedAllow.has(t.tenantId.toLowerCase())) : allTenants),
[allTenants, isDelegatedScope, delegatedAllow]
);
const tenants = useTenantList(isGlobalAdmin);
const [selectedTenantId, setSelectedTenantId] = useState<string>("");
const [scopeInitialized, setScopeInitialized] = useState(false);
@@ -75,17 +93,18 @@ export function useAggregatedAdminScope(opts?: {
const [prevIsGlobalAdmin, setPrevIsGlobalAdmin] = useState(isGlobalAdmin);
// Default the selection on first settle (once tenantId is known) AND on every GA-mode transition.
// This is done DURING RENDER (React's "adjust state when an input changes" escape hatch): React
// discards this render and re-renders before committing, so no committed render — and therefore no
// page fetch keyed on scopeKey — ever observes the transient (isGlobalAdmin && selectedTenantId="")
// scope that an effect-based reset would briefly expose (would otherwise race a stale aggregated
// /global request against the corrected own-tenant request).
// - Regular users (GA off): selection is irrelevant (effectiveTenantId falls back to own tenant);
// clear it so a later GA toggle re-defaults cleanly.
// - GAs on FIRST init with ?global=1: honor the URL seed (specific tenant, or "" = aggregated).
// - GAs otherwise (incl. a later GA toggle-on): default to their own tenant (not aggregated).
if (tenantId && (!scopeInitialized || prevIsGlobalAdmin !== isGlobalAdmin)) {
// Default the selection (done DURING RENDER — React's "adjust state when an input changes" escape hatch;
// it converges, so no committed render keyed on scopeKey observes a transient wrong scope).
if (isDelegatedScope) {
// Delegated: no aggregate. Default to the first managed tenant once the (scoped) list arrives, and
// re-default if the current selection ever falls outside the managed set. Never empty.
if (tenants.length > 0 && (!selectedTenantId || !tenants.some((t) => t.tenantId === selectedTenantId))) {
setSelectedTenantId(tenants[0].tenantId);
if (!scopeInitialized) setScopeInitialized(true);
if (prevIsGlobalAdmin !== isGlobalAdmin) setPrevIsGlobalAdmin(isGlobalAdmin);
}
} else if (tenantId && (!scopeInitialized || prevIsGlobalAdmin !== isGlobalAdmin)) {
// GA/Reader (or a non-global user): default to own tenant, honoring a ?global=1 URL seed on first init.
const firstInit = !scopeInitialized;
setPrevIsGlobalAdmin(isGlobalAdmin);
setScopeInitialized(true);
@@ -99,17 +118,17 @@ export function useAggregatedAdminScope(opts?: {
[tenants, selectedTenantId]
);
const isAggregatedGlobalView = Boolean(isGlobalAdmin && !selectedTenantId);
// A delegated caller is never aggregated (always a concrete managed tenant).
const isAggregatedGlobalView = Boolean(isGlobalAdmin && !selectedTenantId && !isDelegatedScope);
const isGlobalOverride = Boolean(
isGlobalAdmin && selectedTenantId && selectedTenantId !== tenantId
);
const effectiveTenantId = isGlobalAdmin ? selectedTenantId : tenantId;
// Stable identity of the query scope. When GA is off the selection is ignored (own tenant); when on
// it encodes the selected/aggregated target. Toggling GA flips the prefix → page effects refetch.
const scopeKey = isGlobalAdmin ? `ga:${selectedTenantId || "*all*"}` : `tenant:${tenantId}`;
return {
isGlobalAdmin,
isDelegatedScope,
tenants,
selectedTenantId,
setSelectedTenantId,
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTenant } from "@/contexts/TenantContext";
import { useAuth } from "@/contexts/AuthContext";
import { useAdminMode } from "@/hooks/useAdminMode";
@@ -10,62 +10,84 @@ export type { TenantInfo };
export interface GlobalAdminScope {
/**
* True when global-scope mode is toggled on AND the user has platform scope (Global Admin OR the
* read-only Global Reader). This is a VISIBILITY/routing flag — it drives the tenant selector, the
* "Global view" banner and the `/global/` endpoint choice, all of which are read-only-safe. Mutating
* actions on these pages must gate separately on the real Global-Admin / own-tenant-admin status.
* True when cross-tenant mode is on AND the caller has cross-tenant scope — a GlobalAdmin/GlobalReader
* in GA mode, OR a delegated ("MSP") admin (always-on). This is a VISIBILITY/routing flag — it drives the
* tenant selector, the banner and the `/global/` endpoint choice, all read-only-safe. Mutating actions
* gate separately on the real Global-Admin / own-tenant-admin status. Name kept for page compatibility.
*/
isGlobalAdmin: boolean;
/** Sorted tenant list for the selector. Empty unless {@link isGlobalAdmin}. */
/** True when the cross-tenant scope is a delegated ("MSP") subset (not full platform scope). */
isDelegatedScope: boolean;
/** Sorted tenant list for the selector. Empty unless {@link isGlobalAdmin}; bounded to the managed subset for delegated. */
tenants: TenantInfo[];
/** Currently selected tenant in the scope selector. */
selectedTenantId: string;
setSelectedTenantId: (id: string) => void;
/** Tenant to actually query: the GA-override target if one is picked, else the user's own tenant. */
/** Tenant to actually query: the override target if one is picked, else the user's own tenant (delegated: always the managed selection). */
effectiveTenantId: string;
/** GA picked a tenant other than their own → call the cross-tenant `/global/` endpoints. */
/** Cross-tenant caller picked a tenant other than their own → call the cross-tenant `/global/` endpoints. */
isGlobalOverride: boolean;
/** GA mode with no tenant selected → aggregated cross-tenant view. */
/** GA mode with no tenant selected → aggregated cross-tenant view. Never true here / for delegated. */
isAggregatedGlobalView: boolean;
}
/**
* Global-Admin tenant scope for the **override-only** page variant (gather-rules, analyze-rules,
* sla, usage-metrics): the selection always resolves to a concrete tenant — defaulting to the
* caller's own tenant — and endpoint choice is keyed on {@link GlobalAdminScope.isGlobalOverride}.
* There is no aggregated "All tenants" mode here; for that use
* {@link "@/hooks/useAggregatedAdminScope".useAggregatedAdminScope}.
* sla, usage-metrics): the selection always resolves to a concrete tenant — defaulting to the caller's
* own tenant for a GA, or the first managed tenant for a delegated ("MSP") admin — and endpoint choice is
* keyed on {@link GlobalAdminScope.isGlobalOverride}. There is no aggregated "All tenants" mode here.
*
* Pair with {@link "@/components/TenantScopeSelector".TenantScopeSelector} for the header dropdown
* and {@link "@/components/GlobalAdminBanner".GlobalAdminBanner} for the "Global Admin View" bar.
* and {@link "@/components/GlobalAdminBanner".GlobalAdminBanner} for the view bar.
*/
export function useGlobalAdminScope(): GlobalAdminScope {
const { tenantId } = useTenant();
const { hasGlobalScope } = useAuth();
const { hasGlobalScope, user } = useAuth();
const { globalAdminMode } = useAdminMode();
// Platform scope (GA or read-only Global Reader) — read-only-safe cross-tenant view + selector.
const isGlobalAdmin = Boolean(globalAdminMode && hasGlobalScope);
// Cross-tenant mode: GA/Reader in GA mode, OR a delegated ("MSP") admin (always-on). See AggregatedAdminScope.
const isDelegated = user?.isDelegated ?? false;
const isDelegatedScope = Boolean(isDelegated && !hasGlobalScope);
const isGlobalAdmin = Boolean((globalAdminMode && hasGlobalScope) || isDelegatedScope);
const allTenants = useTenantList(isGlobalAdmin);
const delegatedAllow = useMemo(
() => new Set((user?.delegatedTenantIds ?? []).map((t) => t.toLowerCase())),
[user?.delegatedTenantIds]
);
const tenants = useMemo(
() => (isDelegatedScope ? allTenants.filter((t) => delegatedAllow.has(t.tenantId.toLowerCase())) : allTenants),
[allTenants, isDelegatedScope, delegatedAllow]
);
const tenants = useTenantList(isGlobalAdmin);
const [selectedTenantId, setSelectedTenantId] = useState<string>("");
// Default the selection to the user's own tenant (never empty/aggregated in this variant).
// GA/Reader: default the selection to the user's own tenant (never empty in this variant).
useEffect(() => {
if (tenantId && !selectedTenantId) {
if (!isDelegatedScope && tenantId && !selectedTenantId) {
setSelectedTenantId(tenantId);
}
}, [tenantId, selectedTenantId]);
}, [isDelegatedScope, tenantId, selectedTenantId]);
// Delegated: default to the first managed tenant once the scoped list arrives; re-default if the current
// selection falls outside the managed set. Done during render (converging escape hatch).
if (isDelegatedScope && tenants.length > 0 && (!selectedTenantId || !tenants.some((t) => t.tenantId === selectedTenantId))) {
setSelectedTenantId(tenants[0].tenantId);
}
const isGlobalOverride = Boolean(
isGlobalAdmin && selectedTenantId && selectedTenantId !== tenantId
);
const effectiveTenantId =
isGlobalAdmin && selectedTenantId ? selectedTenantId : tenantId;
const isAggregatedGlobalView = Boolean(isGlobalAdmin && !selectedTenantId);
// Delegated has no valid own-tenant data, so before a managed tenant is selected resolve to "" (empty)
// — pages gate their fetch on a truthy effectiveTenantId, so this avoids a transient own-tenant request.
const effectiveTenantId = isGlobalAdmin && selectedTenantId
? selectedTenantId
: (isDelegatedScope ? "" : tenantId);
const isAggregatedGlobalView = Boolean(isGlobalAdmin && !selectedTenantId && !isDelegatedScope);
return {
isGlobalAdmin,
isDelegatedScope,
tenants,
selectedTenantId,
setSelectedTenantId,
@@ -141,12 +141,21 @@ export const NAV_GROUPS: NavGroup[] = [
{
// Delegated ("MSP") admins manage a subset of tenants. This is their primary entry point; full
// platform admins (GA/Reader) use the richer Global Admin section instead, so it is hidden for them
// (see GlobalSidebar "fleet" visibility = delegated && !platform scope).
// (see GlobalSidebar "fleet" visibility = delegated && !platform scope). The analytics items below are
// the SAME pages a Global Admin sees, but the tenant switcher on each is bounded to the managed subset.
id: "fleet",
label: "Fleet",
visibility: "fleet",
items: [
{ id: "fleet-tenants", label: "Managed Tenants", href: "/fleet", icon: <GlobeAltIcon /> },
{ id: "fleet-fleet-health", label: "Fleet Health", href: "/fleet-health", icon: <ChartBarIcon /> },
{ id: "fleet-apps", label: "Software", href: "/apps", icon: <CubeIcon /> },
{ id: "fleet-geographic", label: "Geographic Perf.", href: "/geographic-performance", icon: <GlobeAltIcon /> },
{ id: "fleet-sla", label: "SLA Compliance", href: "/sla", icon: <ShieldCheckIcon /> },
{ id: "fleet-usage", label: "Usage Metrics", href: "/usage-metrics", icon: <TrendingUpIcon /> },
// Audit Log deferred for delegated: the page drives off raw globalAdminMode with no tenant selector
// and its global/audit/logs read is GA-aggregate (no tenantId) — wiring a scoped selector is a
// separate follow-up. The backend route stays QueryParam so a delegated ?tenantId= read is possible.
],
},
{