mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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)]
|
||||
|
||||
+6
-6
@@ -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,
|
||||
|
||||
+24
-5
@@ -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}");
|
||||
}
|
||||
|
||||
+16
-4
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-4
@@ -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'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'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">— {subtitle}</span>
|
||||
<span className="font-medium">{delegated ? "Delegated Admin View" : "Global Admin View"}</span>
|
||||
<span className={subtitleClass}>— {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.
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user