Compare commits

..

9 Commits

Author SHA1 Message Date
Oliver Kieselbach 456b8f6025 bump pointer: internal (D-438 released: web and MCP 1.6.600 on c3faece)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:12:50 +01:00
Oliver Kieselbach c3faece6d8 bump pointer: internal, .claude/commands (D-438 built: vulnerability visibility)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:55:54 +01:00
Oliver Kieselbach f0fee75b08 Web: Vulnerabilities card on the dashboard, count on the Software tab, analyzer-off notices
- Dashboard: a sixth stat card shows the known exploited CVEs (CISA KEV) of the last
  30 days for the one tenant in view and opens Software > Vulnerabilities; it reads Off
  while the Software Inventory & Vulnerability Analyzer is off. All stat cards share one
  layout (icon in the title row, tighter padding); six cards sit in one row from a row
  width of 1126 px, else 3 + 3 or 2 + 2 + 2, as container queries in globals.css.
- Software hub: a red known-exploited count on the Vulnerabilities tab. With the analyzer
  off, the Vulnerabilities and Inventory tabs say so instead of an empty list; users who
  can save the setting get a link that opens it with the switch highlighted.
- A session's Vulnerability Report and an app's detail page link to the tab; the hub takes
  ?tenantId= for a platform admin, like the app detail page.
- Telemetry: vuln_card_shown, vuln_link_clicked, vuln_enable_clicked,
  software_tab_selected, vuln_exposure_viewed, vuln_exposure_action and
  session_vuln_report_action. No CVE ids, session ids or product names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:55:06 +01:00
Oliver Kieselbach ac381a39ad bump pointer: internal (D-437 released: backend 1.5.1333, web on a1936b7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:54:30 +01:00
Oliver Kieselbach a1936b7cb6 bump pointer: internal, .claude/commands (D-437 built: Admin Mode by role, session writes in the session's tenant)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:37:24 +01:00
Oliver Kieselbach ba53b82c00 Web: Admin Mode takes effect only for tenant Admins and Global Admins
The stored Admin Mode flag is shared by every account in a browser. It now
takes effect, and its toggle shows, only for a tenant Admin or a Global
Admin (canUseAdminMode); the stored value stays for the account that set it.
Dashboard rows offer delete and the Select checkbox only where the backend
admits the caller for the row's tenant (canAdministerTenant), the
blocked-device read runs only in the platform view, and a failed
Succeed/Fail shows an error notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:35:30 +01:00
Oliver Kieselbach 7f53dcf223 Backend: Succeed, Fail and session actions act in the session's tenant
mark-failed, mark-succeeded and actions on sessions/{sessionId} take the
tenant from ?tenantId= (TenantScoping.QueryParam) and resolve it like the
other session write routes (ResolveSessionScopeAsync with
requireGlobalAdmin). Before, they used the caller's token tenant, so a
Global Admin on another tenant's session got 404. A test pins that every
sessions/{sessionId} route is query-scoped; the report keeps the tenant
from its body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:35:21 +01:00
Oliver Kieselbach cd2ea05878 Web: "/" jumps to the event timeline search, Esc clears it
On the session page "/" focuses the timeline search from anywhere: the
controls row scrolls up below the navbar, so the matches fill the screen,
and a previous query is selected for overtyping. It is the web's usual
key for a page's own search and the same on Windows and Mac; Shift stays
allowed, since layouts like German type "/" as Shift+7. A "/" typed into
any field still belongs to that field. An empty box shows a "/" hint.

Esc in the box clears the query and keeps the cursor there for the next
term; Esc on an empty box leaves it.

The "is the user typing in a field" check moves to lib/keyboardTarget.ts
and is shared with the dashboard's Esc handler for select mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:08:34 +01:00
Oliver Kieselbach ccdd8c17ce bump pointer: internal (D-436 released: agent 2.0.1505, backend 1.5.1332, web and MCP 1.6.598 on e619183)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 22:27:39 +01:00
38 changed files with 1394 additions and 176 deletions
@@ -0,0 +1,174 @@
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// A write on one session acts in that session's tenant: the portal sends it as ?tenantId=, the
/// middleware validates it (TenantScoping.QueryParam), and a Global Admin on a foreign tenant's session
/// reaches that tenant. Before, mark-failed/-succeeded and actions took the caller's token tenant and
/// answered 404 for a Global Admin on a foreign session.
/// </summary>
public class SessionWriteTenantScopeTests
{
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
private const string OtherTenant = "22222222-2222-2222-2222-222222222222";
private const string SessionId = "33333333-3333-3333-3333-333333333333";
/// <summary>
/// Every route on one session takes the tenant from ?tenantId=. The only exception, the report,
/// carries the session's tenant in its body and checks it in the function itself.
/// </summary>
[Fact]
public void Every_session_route_is_query_param_scoped()
{
var exceptions = new HashSet<string> { "POST sessions/{sessionId}/report" };
var routes = EndpointAccessPolicyCatalog.Entries
.Where(e => e.RouteTemplate.StartsWith("sessions/{sessionId}", StringComparison.Ordinal))
.ToList();
Assert.NotEmpty(routes);
var unscoped = routes
.Where(e => e.TenantScoping != TenantScoping.QueryParam)
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
.Where(r => !exceptions.Contains(r))
.ToList();
Assert.True(unscoped.Count == 0,
"Session routes must take the session's tenant from ?tenantId= (TenantScoping.QueryParam):\n"
+ string.Join("\n", unscoped.Select(r => $" - {r}")));
foreach (var exception in exceptions)
Assert.Contains(routes, e => $"{e.HttpMethod} {e.RouteTemplate}" == exception);
}
[Theory]
[InlineData("failed")]
[InlineData("succeeded")]
public async Task Mark_by_a_global_admin_acts_in_the_session_tenant(string outcome)
{
var repo = SessionRepo();
var req = Request(GlobalAdmin());
await RunMark(outcome, repo, req);
Assert.Equal(OtherTenant, StatusUpdateTenant(repo));
}
[Theory]
[InlineData("failed")]
[InlineData("succeeded")]
public async Task Mark_by_a_tenant_admin_stays_in_the_validated_tenant(string outcome)
{
var repo = SessionRepo();
var req = Request(TenantAdmin());
await RunMark(outcome, repo, req);
Assert.Equal(HomeTenant, StatusUpdateTenant(repo));
}
[Fact]
public async Task Action_by_a_global_admin_is_queued_in_the_session_tenant()
{
var repo = SessionRepo();
var req = Request(GlobalAdmin(), """{"type":"request_diagnostics"}""");
await ActionFunction(repo).Run(req, SessionId);
repo.Verify(r => r.QueueServerActionAsync(OtherTenant, SessionId, It.IsAny<ServerAction>()), Times.Once);
}
[Fact]
public async Task Action_by_an_operator_stays_in_the_validated_tenant()
{
var repo = SessionRepo();
var operatorContext = new RequestContext
{
TenantId = HomeTenant,
TargetTenantId = HomeTenant,
UserPrincipalName = "operator@contoso.com",
UserRole = Constants.TenantRoles.Operator,
};
var req = Request(operatorContext, """{"type":"request_diagnostics"}""");
await ActionFunction(repo).Run(req, SessionId);
repo.Verify(r => r.QueueServerActionAsync(HomeTenant, SessionId, It.IsAny<ServerAction>()), Times.Once);
}
// The lookup always points at the other tenant, so only the caller's reach decides where the write lands.
private static Mock<ISessionRepository> SessionRepo()
{
var repo = new Mock<ISessionRepository>(MockBehavior.Loose);
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
repo.Setup(r => r.GetSessionAsync(OtherTenant, SessionId))
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = OtherTenant });
return repo;
}
private static RequestContext GlobalAdmin() => new()
{
TenantId = HomeTenant,
TargetTenantId = HomeTenant,
UserPrincipalName = "ga@contoso.com",
IsGlobalAdmin = true,
UserRole = Constants.GlobalRoles.GlobalAdmin,
};
private static RequestContext TenantAdmin() => new()
{
TenantId = HomeTenant,
TargetTenantId = HomeTenant,
UserPrincipalName = "admin@contoso.com",
IsTenantAdmin = true,
UserRole = Constants.TenantRoles.Admin,
};
private static HttpRequestData Request(RequestContext requestContext, string? body = null)
{
var (req, context) = EndpointHarness.Request(HomeTenant, jsonBody: body);
context.Items["RequestContext"] = requestContext;
return req;
}
private static async Task RunMark(string outcome, Mock<ISessionRepository> repo, HttpRequestData req)
{
var maintenance = Mock.Of<IMaintenanceRepository>();
if (outcome == "failed")
await new MarkSessionFailedFunction(NullLogger<MarkSessionFailedFunction>.Instance, repo.Object, maintenance).Run(req, SessionId);
else
await new MarkSessionSucceededFunction(NullLogger<MarkSessionSucceededFunction>.Instance, repo.Object, maintenance).Run(req, SessionId);
}
private static string? StatusUpdateTenant(Mock<ISessionRepository> repo)
{
var call = Assert.Single(repo.Invocations, i => i.Method.Name == nameof(ISessionRepository.UpdateSessionStatusAsync));
return call.Arguments[0] as string;
}
private static QueueSessionActionFunction ActionFunction(Mock<ISessionRepository> repo)
{
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var opsEvents = new OpsEventService(
Mock.Of<IOpsEventRepository>(), NullLogger<OpsEventService>.Instance,
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
return new QueueSessionActionFunction(
NullLogger<QueueSessionActionFunction>.Instance, repo.Object,
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }), opsEvents);
}
}
@@ -35,8 +35,10 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
string tenantId = TenantHelper.GetTenantId(req);
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware.
// The session's tenant: a Global Admin reaches a foreign tenant's session, every other
// caller stays in the tenant the middleware validated (?tenantId=, TenantScoping.QueryParam).
string tenantId = await req.GetRequestContext().ResolveSessionScopeAsync(_sessionRepo, sessionId, requireGlobalAdmin: true);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
_logger.LogInformation($"Marking session {sessionId} as failed for tenant {tenantId} by user {userIdentifier}");
@@ -35,8 +35,10 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
string tenantId = TenantHelper.GetTenantId(req);
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware.
// The session's tenant: a Global Admin reaches a foreign tenant's session, every other
// caller stays in the tenant the middleware validated (?tenantId=, TenantScoping.QueryParam).
string tenantId = await req.GetRequestContext().ResolveSessionScopeAsync(_sessionRepo, sessionId, requireGlobalAdmin: true);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
_logger.LogInformation($"Marking session {sessionId} as succeeded for tenant {tenantId} by user {userIdentifier}");
@@ -80,9 +80,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
{
// Authentication + TenantAdminOrOperator authorization enforced by
// PolicyEnforcementMiddleware; per-type re-gating for Operators happens below.
var tenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
// The session's tenant: a Global Admin reaches a foreign tenant's session, every other
// caller stays in the tenant the middleware validated (?tenantId=, TenantScoping.QueryParam).
var requestCtx = req.GetRequestContext();
var tenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId, requireGlobalAdmin: true);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var read = await req.ReadAsync<QueueSessionActionRequest>();
if (read.Error != null) return read.Error;
@@ -404,12 +404,12 @@ public static class EndpointAccessPolicyCatalog
new("POST", "rules/analyze/{ruleId}/create-from-template", EndpointPolicy.TenantAdminOrGA),
new("PUT", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
new("DELETE", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-failed", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-succeeded", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-failed", EndpointPolicy.TenantAdminOrGA, TenantScoping.QueryParam),
new("POST", "sessions/{sessionId}/mark-succeeded", EndpointPolicy.TenantAdminOrGA, TenantScoping.QueryParam),
// Operator tier so troubleshooting staff can queue request_diagnostics ("Collect Logs" in
// the portal); the function re-gates the admin-only action types (terminate_session,
// rotate_config) on RequestContext.IsTenantAdmin || IsGlobalAdmin.
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrOperator),
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam),
// Operator tier so troubleshooting staff can write the operator annotation lane; the
// function re-gates the per-lane matrix (tenantadmin lane → IsTenantAdmin, globaladmin
// lane → IsGlobalAdmin) via UpsertSessionAnnotationFunction.IsLaneWritableByCaller.
@@ -8,6 +8,7 @@ import { api } from "@/lib/api";
import type { SoftwareTabScope } from "./types";
import { ApiError, fetchJson } from "@/lib/apiClient";
import { notifyApiError } from "@/contexts/NotificationContext";
import { AnalyzerOffNotice } from "@/components/vulnerability/AnalyzerOffNotice";
interface InventoryItem {
displayName: string;
@@ -33,7 +34,17 @@ interface InventoryResponse {
const PAGE_SIZE = 20;
export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
export default function InventoryTab({
scope,
analyzerOn,
canEnable,
}: {
scope: SoftwareTabScope;
/** The tenant's analyzer switch: null while it loads, undefined when unknown. */
analyzerOn?: boolean | null;
/** May turn the analyzer on from here (lib/tenantScope.ts canChangeOwnTenantSettings). */
canEnable: boolean;
}) {
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
@@ -110,6 +121,11 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
);
}
// Nothing collected because the analyzer is off: say so instead of empty tiles and a search box.
if (!loading && analyzerOn === false && (resp?.total ?? 0) === 0) {
return <AnalyzerOffNotice subject="inventory" canEnable={canEnable} />;
}
return (
<>
{resp && (
@@ -146,7 +162,9 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
<div className="p-8 text-center text-gray-500">
{search
? "No software matches your search."
: "No software inventory collected for this tenant yet. It fills once the Software Inventory & Vulnerability Analyzer is on in the agent settings."}
: analyzerOn
? "No software inventory collected for this tenant yet. It fills with the next enrollment."
: "No software inventory collected for this tenant yet. It fills once the Software Inventory & Vulnerability Analyzer is on in the agent settings."}
</div>
) : (
<>
@@ -5,24 +5,58 @@ import { useAuth } from "../../../contexts/AuthContext";
import { useNotifications } from "../../../contexts/NotificationContext";
import { scopedApi } from "@/lib/scopedApi";
import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel";
import { AnalyzerOffNotice } from "@/components/vulnerability/AnalyzerOffNotice";
import type { CveExposureSummary } from "@/lib/generated/wire-types.generated";
import type { SoftwareTabScope } from "./types";
import { windowProgressKey } from "@/lib/timeWindow";
import { ApiError, fetchJson } from "@/lib/apiClient";
import { notifyApiError } from "@/contexts/NotificationContext";
import { useFetchProgress } from "@/hooks/useFetchProgress";
import { trackEvent } from "@/lib/appInsights";
import type { ExposureAction } from "@/lib/vulnerabilityExposure";
const TOP_N = 20;
/** Per severity/priority band, its top entries outside the lists, so every band filter shows something. */
const PER_BAND = 10;
export default function VulnerabilitiesTab({ scope, days, windowReady }: { scope: SoftwareTabScope; days: number; windowReady: boolean }) {
/** Counts a filter, lens, sort or expand step; never a CVE id or a product name. */
function trackExposureAction({ action, value, active }: ExposureAction) {
trackEvent("vuln_exposure_action", {
action,
...(value !== undefined ? { value } : {}),
...(active !== undefined ? { active } : {}),
});
}
/** A completed load: the summary with the window and scope it was read for. */
interface LoadedExposure {
summary: CveExposureSummary;
days: number;
crossTenant: boolean;
}
export default function VulnerabilitiesTab({
scope,
days,
windowReady,
analyzerOn,
canEnable,
}: {
scope: SoftwareTabScope;
days: number;
windowReady: boolean;
/** The tenant's analyzer switch: null while it loads, undefined in an aggregate view or when unreadable. */
analyzerOn?: boolean | null;
/** May turn the analyzer on from here (lib/tenantScope.ts canChangeOwnTenantSettings). */
canEnable: boolean;
}) {
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope;
const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope;
const [summary, setSummary] = useState<CveExposureSummary | null>(null);
const [loaded, setLoaded] = useState<LoadedExposure | null>(null);
const [loading, setLoading] = useState(true);
const summary = loaded?.summary ?? null;
// GA without a specific tenant selected sees the cross-tenant aggregate (incl. affected tenants).
const showTenantCount = isGlobalAdmin && !selectedTenantId;
@@ -45,7 +79,7 @@ export default function VulnerabilitiesTab({ scope, days, windowReady }: { scope
const url = scopedApi.vulnerability(scope, days, TOP_N, PER_BAND);
const summary = await fetchJson<CveExposureSummary>(url, getAccessToken);
if (cancelled) return;
setSummary(summary);
setLoaded({ summary, days, crossTenant: showTenantCount });
succeeded = true;
} catch (err) {
if (cancelled) return;
@@ -69,12 +103,41 @@ export default function VulnerabilitiesTab({ scope, days, windowReady }: { scope
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeInitialized, windowReady, scopeKey, days]);
const hasFindings = !!summary && summary.distinctCves > 0;
const analyzerOff = analyzerOn === false;
// What the tab showed, once per completed load and only after the analyzer switch is known, so an
// empty tenant is told apart from one whose scanning is off.
const viewState = !loaded || loading || analyzerOn === null
? null
: loaded.summary.distinctCves > 0 ? "findings" : analyzerOff ? "analyzer_off" : "empty";
useEffect(() => {
if (!viewState || !loaded) return;
trackEvent("vuln_exposure_viewed", {
state: viewState,
scope: loaded.crossTenant ? "global" : "tenant",
days: loaded.days,
cves: loaded.summary.distinctCves,
kev: loaded.summary.kevCves,
highRisk: loaded.summary.priorityBreakdown.attend,
});
}, [viewState, loaded]);
return (
<VulnerabilityExposurePanel
summary={summary}
loading={loading}
showTenantCount={showTenantCount}
progress={{ elapsedMs: progress.elapsedMs, estimateMs: progress.estimateMs }}
/>
<div className="space-y-4">
{analyzerOff && hasFindings && (
<p className="text-sm text-gray-500">
Scanning is off for this tenant: these findings are from before, new enrollments are not scanned.
</p>
)}
<VulnerabilityExposurePanel
summary={summary}
loading={loading}
showTenantCount={showTenantCount}
progress={{ elapsedMs: progress.elapsedMs, estimateMs: progress.estimateMs }}
emptyState={analyzerOff ? <AnalyzerOffNotice subject="vulnerabilities" canEnable={canEnable} /> : undefined}
onAction={trackExposureAction}
/>
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { appsHubUrl, sessionUrl } from "@/lib/routes";
import Link from "next/link";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import dynamic from "next/dynamic";
@@ -364,16 +365,26 @@ function AppDetailContent() {
<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
onClick={() => {
// Tenant scope is carried in sessionStorage, so the list restores it on its own;
// a non-default window travels back as ?days=.
router.push(appsHubUrl({ days: days === APPS_DEFAULT_WINDOW_DAYS ? undefined : days }));
}}
className="text-sm text-green-700 hover:underline mb-2 inline-flex items-center"
>
← Back to all apps
</button>
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
<button
onClick={() => {
// Tenant scope is carried in sessionStorage, so the list restores it on its own;
// a non-default window travels back as ?days=.
router.push(appsHubUrl({ days: days === APPS_DEFAULT_WINDOW_DAYS ? undefined : days }));
}}
className="text-sm text-green-700 hover:underline inline-flex items-center"
>
← Back to all apps
</button>
{/* The hub's exposure view, same scope (sessionStorage) and window; not filtered to this app. */}
<Link
href={appsHubUrl({ tab: "vulnerabilities", days: days === APPS_DEFAULT_WINDOW_DAYS ? undefined : days })}
onClick={() => trackEvent("vuln_link_clicked", { from: "app_detail" })}
className="text-sm text-green-700 hover:underline"
>
Vulnerability exposure →
</Link>
</div>
<div className="flex flex-wrap items-center justify-between gap-y-3">
<div className="min-w-0">
<h1 className="text-2xl font-normal text-gray-900 flex flex-wrap items-center gap-x-3 gap-y-1">
@@ -15,6 +15,11 @@ import InventoryTab from "./components/InventoryTab";
import VulnerabilitiesTab from "./components/VulnerabilitiesTab";
import { DocsLink } from "@/components/DocsLink";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { useAuth } from "@/contexts/AuthContext";
import { useTenant } from "@/contexts/TenantContext";
import { trackEvent } from "@/lib/appInsights";
import { canChangeOwnTenantSettings } from "@/lib/tenantScope";
import { useVulnerabilityHeadline } from "@/hooks/useVulnerabilityHeadline";
const TABS = [
{ id: "installs", label: "Installs" },
@@ -26,14 +31,29 @@ type TabId = (typeof TABS)[number]["id"];
function SoftwareHub() {
const router = useRouter();
const searchParams = useSearchParams();
const scope = useAggregatedAdminScope();
const { user } = useAuth();
const { tenantId: ownTenantId } = useTenant();
// ?tenantId= (dashboard card, session report) points a Global Admin at that tenant on first load.
const scope = useAggregatedAdminScope({ urlTenantId: searchParams.get("tenantId") ?? "" });
// The window lives in the URL (?days=) and survives tab switches (selectTab keeps every parameter).
const { days, ready: windowReady, setDays } = useWindowDays({ defaultDays: APPS_DEFAULT_WINDOW_DAYS, tenantId: scope.effectiveTenantId });
// Analyzer switch and known-exploited count of the tenant in view: the count goes on the
// Vulnerabilities tab label, the switch decides both tabs' empty state. Only the active tab is
// mounted, so the page reads them itself. The aggregated Global Admin view (the slow cross-tenant
// scan) reads nothing.
const singleTenant = scope.scopeInitialized && windowReady && !scope.isAggregatedGlobalView && !!scope.effectiveTenantId;
const headline = useVulnerabilityHeadline(singleTenant ? scope : null, singleTenant ? scope.effectiveTenantId : null, days);
const kev = headline.status === "ready" ? headline.kev : 0;
// undefined = no single tenant or not readable; null = still loading.
const analyzerOn = headline.status === "ready" ? headline.analyzerOn : headline.status === "loading" ? null : undefined;
const canEnable = canChangeOwnTenantSettings(user, scope.effectiveTenantId, ownTenantId);
const rawTab = searchParams.get("tab");
const activeTab: TabId = TABS.some((t) => t.id === rawTab) ? (rawTab as TabId) : "installs";
function selectTab(id: TabId) {
trackEvent("software_tab_selected", { tab: id, badge: kev > 0, kev });
const params = new URLSearchParams(searchParams.toString());
params.set("tab", id);
router.replace(`/apps?${params.toString()}`);
@@ -75,6 +95,12 @@ function SoftwareHub() {
}`}
>
{t.label}
{t.id === "vulnerabilities" && kev > 0 && (
<span className="ml-2 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700">
<span aria-hidden="true">{kev.toLocaleString()}</span>
<span className="sr-only">{`${kev.toLocaleString()} known exploited`}</span>
</span>
)}
</button>
))}
</nav>
@@ -83,8 +109,10 @@ function SoftwareHub() {
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{activeTab === "installs" && <InstallsTab scope={scope} days={days} windowReady={windowReady} />}
{activeTab === "inventory" && <InventoryTab scope={scope} />}
{activeTab === "vulnerabilities" && <VulnerabilitiesTab scope={scope} days={days} windowReady={windowReady} />}
{activeTab === "inventory" && <InventoryTab scope={scope} analyzerOn={analyzerOn} canEnable={canEnable} />}
{activeTab === "vulnerabilities" && (
<VulnerabilitiesTab scope={scope} days={days} windowReady={windowReady} analyzerOn={analyzerOn} canEnable={canEnable} />
)}
</main>
</div>
);
@@ -8,6 +8,7 @@ import { useState, useEffect, useRef, useMemo, useDeferredValue } from "react";
import { Session } from "../types";
import { trackEvent } from "@/lib/appInsights";
import { fuzzyContains } from "@/lib/fuzzy";
import { isTypingInField } from "@/lib/keyboardTarget";
import { buildUniqueValuesByField } from "./uniqueValuesByField";
import {
SESSION_SEARCH_FIELDS,
@@ -21,6 +22,7 @@ import { TenantFilterBar } from "./TenantFilterBar";
import type { DeleteTarget } from "../hooks/useDeleteSession";
import type { BlockTarget } from "../hooks/useBlockDevice";
import { BULK_MAX_TARGETS, addSelections } from "../hooks/bulkActions";
import { canAdministerTenant, type TenantScopeUser } from "@/lib/tenantScope";
// Column definition for the session table
interface ColumnDef {
@@ -104,7 +106,9 @@ interface SessionTableProps {
tenantDomainById: Map<string, string>;
blockedDevicesSet: Set<string>;
isActivationPending: boolean;
user: { isGlobalAdmin?: boolean } | null;
user: TenantScopeUser | null;
/** The caller's own tenant: a tenant Admin may delete only its sessions (canAdministerTenant). */
ownTenantId: string;
columnFilters: Record<string, Set<string>>;
onColumnFiltersChange: (filters: Record<string, Set<string>>) => void;
/** Opens the delete confirm for one session (row icon) or many (Select mode). */
@@ -160,6 +164,7 @@ export function SessionTable({
blockedDevicesSet,
isActivationPending,
user,
ownTenantId,
columnFilters,
onColumnFiltersChange,
onDeleteSessions,
@@ -333,7 +338,10 @@ export function SessionTable({
const canBlock = globalAdminMode && !!user?.isGlobalAdmin;
const colSpan = activeColumns.length + (selecting ? 1 : 0);
const isSelectable = (s: Session) => !pendingDeletions.has(s.sessionId);
// A delegated admin or Global Reader with an own Admin role sees foreign tenants' rows too; the
// backend refuses every write there, so those rows get no delete icon and no checkbox.
const canAdminister = (s: Session) => canAdministerTenant(user, s.tenantId, ownTenantId);
const isSelectable = (s: Session) => !pendingDeletions.has(s.sessionId) && canAdminister(s);
// Effective selection = selected ∩ currently visible (filtered) ∩ not already queued for
// deletion, cut to BULK_MAX_TARGETS. Derived at read time so rows that vanish (deleted,
@@ -398,9 +406,7 @@ export function SessionTable({
useEffect(() => {
if (!selecting) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
const tag = (e.target as HTMLElement | null)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key !== "Escape" || isTypingInField(e.target)) return;
setSelectMode(false);
setSelectedIds(new Set());
lastToggledIdRef.current = null;
@@ -943,11 +949,11 @@ export function SessionTable({
columnKey={col.key}
session={session}
href={selecting ? null : linkFor(session)}
adminMode={adminMode}
globalAdminMode={globalAdminMode}
blockedDevicesSet={blockedDevicesSet}
user={user}
selectMode={selecting}
canDelete={canAdminister(session)}
onDeleteSessions={onDeleteSessions}
isDeletionPending={pendingDeletions.has(session.sessionId)}
onBlockDevices={onBlockDevices}
@@ -1053,6 +1059,7 @@ function SessionCell({
blockedDevicesSet,
user,
selectMode,
canDelete,
onDeleteSessions,
isDeletionPending,
onBlockDevices,
@@ -1062,12 +1069,13 @@ function SessionCell({
session: Session;
/** Null while selecting — cells then render no navigation overlay. */
href: Route | null;
adminMode: boolean;
globalAdminMode: boolean;
blockedDevicesSet: Set<string>;
user: { isGlobalAdmin?: boolean } | null;
/** Row-level action icons are hidden while selecting; the bulk bar owns the actions then. */
selectMode: boolean;
/** The caller may delete this session (canAdministerTenant); otherwise the row shows no delete icon. */
canDelete: boolean;
onDeleteSessions: (targets: DeleteTarget[]) => void;
/** True when the V2 cascade has been queued for this session and we're awaiting `sessionDeleted`. */
isDeletionPending: boolean;
@@ -1339,7 +1347,7 @@ function SessionCell({
<path d="M4 12a8 8 0 018-8" strokeWidth="3" className="opacity-75" />
</svg>
</span>
) : selectMode ? null : (
) : selectMode || !canDelete ? null : (
<button
onClick={(e) => {
e.stopPropagation();
@@ -1,8 +1,12 @@
"use client";
import type { ReactNode } from "react";
import type { Route } from "next";
import Link from "next/link";
export type StatsCardIcon = "activity" | "success" | "duration" | "today" | "failed";
export type StatsCardIcon = "activity" | "success" | "duration" | "today" | "failed" | "shield" | "shieldAlert";
const SHIELD = "M12 21.5s7.5-3.6 7.5-9.4V5.4L12 2.5 4.5 5.4v6.7c0 5.8 7.5 9.4 7.5 9.4z";
// Outline glyphs on a 24px grid, one per metric.
const iconShapes: Record<StatsCardIcon, ReactNode> = {
@@ -35,45 +39,139 @@ const iconShapes: Record<StatsCardIcon, ReactNode> = {
<path d="m9 9 6 6" />
</>
),
shield: <path d={SHIELD} />,
shieldAlert: (
<>
<path d={SHIELD} />
<path d="M12 8v4.5M12 15.8h.01" />
</>
),
};
/**
* Columns of the stat row; the wrapper around it carries `stats-row` (the size container). Six cards
* follow the width the row itself gets, set in app/globals.css (`stats-grid-6`: six columns from
* 1126 px, which a 1440 px window with the full sidebar reaches, three from 553 px, two from 362 px).
* Five cards (the cross-tenant views) keep the window breakpoints.
*/
export function statsGridClassName(count: number): string {
return count >= 6 ? "stats-grid-6" : "grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5";
}
/** The link stretched over the whole card; the card is its positioning context. */
const STRETCHED_LINK = "after:absolute after:inset-0 after:rounded-lg focus:outline-none";
export function StatsCard({
title,
value,
description,
icon,
alert = false,
muted = false,
href,
linkOn = "title",
onClick,
}: {
title: string;
value: string;
description: string;
/** One line, or parts that wrap as whole units, separated by a dot that never starts a line. */
description: string | readonly string[];
icon: StatsCardIcon;
/** Colors value and icon red — the only color on the row, reserved for a metric that needs attention. */
alert?: boolean;
/** Gray value: the metric is off or not available. */
muted?: boolean;
/** Makes the whole card a link. */
href?: Route;
/** "description": the description is the visible call to action (green); otherwise the title carries the link. */
linkOn?: "title" | "description";
onClick?: () => void;
}) {
const linked = !!href;
const titleLink = linked && linkOn === "title";
const descriptionLink = linked && linkOn === "description";
const parts = typeof description === "string" ? [description] : description;
return (
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-start justify-between gap-3">
<dl className="min-w-0">
<dt className="text-sm font-medium text-gray-500 truncate">{title}</dt>
<dd className={`text-2xl font-semibold ${alert ? "text-red-600" : "text-gray-900"}`}>{value}</dd>
</dl>
<svg
className={`h-5 w-5 flex-shrink-0 ${alert ? "text-red-600" : "text-gray-500"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{iconShapes[icon]}
</svg>
</div>
<div className="mt-2">
<div className="text-sm text-gray-500">{description}</div>
<div
className={`relative bg-white overflow-hidden shadow rounded-lg${
linked
? " group transition-shadow hover:shadow-md hover:ring-1 hover:ring-gray-300 dark:hover:ring-gray-600 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-green-600 motion-reduce:transition-none"
: ""
}`}
>
<div className="p-4">
<dl className="min-w-0">
<dt className="flex min-w-0 items-center gap-2 text-sm font-medium text-gray-500">
<span
className={`flex min-w-0 flex-1 items-center gap-1${
titleLink ? " group-hover:text-gray-900 dark:group-hover:text-gray-100" : ""
}`}
>
{titleLink && href ? (
<>
<Link href={href} onClick={onClick} className={`truncate ${STRETCHED_LINK}`}>
{title}
</Link>
<svg
className="h-3.5 w-3.5 shrink-0 -translate-x-1 opacity-0 transition group-hover:translate-x-0 group-hover:opacity-100 group-focus-within:translate-x-0 group-focus-within:opacity-100 motion-reduce:transition-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</>
) : (
<span className="truncate">{title}</span>
)}
</span>
<svg
className={`h-4 w-4 shrink-0 ${alert ? "text-red-600" : "text-gray-500"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{iconShapes[icon]}
</svg>
</dt>
<dd className={`text-2xl font-semibold ${alert ? "text-red-600" : muted ? "text-gray-500" : "text-gray-900"}`}>
{value}
</dd>
</dl>
<div className="mt-2 text-sm text-gray-500">
{descriptionLink && href ? (
<Link
href={href}
onClick={onClick}
className={`font-medium text-green-700 hover:text-green-800 dark:text-green-400 ${STRETCHED_LINK}`}
>
{parts.join(" · ")}
</Link>
) : parts.length === 1 ? (
parts[0]
) : (
// Each part wraps as a whole; the dot before a part that starts a line sits in the clipped margin.
<div className="overflow-hidden">
<div className="-ml-[15px] flex flex-wrap">
{parts.map((part, i) => (
<span key={`${i}-${part}`} className="flex min-w-0">
<span aria-hidden="true" className="w-[15px] shrink-0 text-center">·</span>
{i > 0 && <span className="sr-only">, </span>}
<span className="min-w-0">{part}</span>
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { Route } from "next";
import { StatsCard, statsGridClassName } from "../StatsCards";
type CardProps = Parameters<typeof StatsCard>[0];
const render = (props: Partial<CardProps> = {}) =>
renderToStaticMarkup(
createElement(StatsCard, { title: "Vulnerabilities", value: "3", description: "Known exploited", icon: "shield", ...props }),
);
describe("StatsCard", () => {
it("is no link without an href", () => {
const html = render();
expect(html).not.toContain("<a ");
expect(html).toContain("Vulnerabilities");
expect(html).not.toContain("group");
});
it("stretches the title link over the card and shows the arrow on hover", () => {
const html = render({ href: "/apps?tab=vulnerabilities" as Route });
expect(html).toContain('href="/apps?tab=vulnerabilities"');
expect(html).toContain("after:absolute after:inset-0");
expect(html).toContain("group-hover:opacity-100");
expect(html).toMatch(/<a [^>]*>Vulnerabilities<\/a>/);
});
it("makes the description the call to action when the link sits there", () => {
const html = render({ href: "/settings/agent/analyzers" as Route, linkOn: "description", value: "Off", description: ["Turn on scanning →"], muted: true });
expect(html).toMatch(/<a [^>]*text-green-700[^>]*>Turn on scanning →<\/a>/);
expect(html).not.toContain("group-hover:opacity-100");
});
it("colors only an alert red and a muted value gray", () => {
expect(render({ alert: true })).toContain("text-2xl font-semibold text-red-600");
expect(render({ muted: true })).toContain("text-2xl font-semibold text-gray-500");
expect(render()).toContain("text-2xl font-semibold text-gray-900");
});
it("renders description parts as wrapping units with a hidden separator for screen readers", () => {
const html = render({ description: ["Known exploited", "41 devices"] });
expect(html).toContain("-ml-[15px] flex flex-wrap");
expect(html).toContain("Known exploited");
expect(html).toContain("41 devices");
expect(html).toContain('<span class="sr-only">, </span>');
});
});
describe("statsGridClassName", () => {
it("lays six cards out by the row's own width (globals.css) and five by the window", () => {
expect(statsGridClassName(6)).toBe("stats-grid-6");
expect(statsGridClassName(5)).toBe("grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5");
});
it("keeps the container rules of the six-card row in ascending order, the cascade they rely on", () => {
const css = readFileSync(join(__dirname, "..", "..", "..", "globals.css"), "utf8");
const widths = [...css.matchAll(/@container \(min-width: (\d+)px\) \{\s*\.stats-grid-6 \{ grid-template-columns: repeat\((\d), /g)]
.map((m) => [Number(m[1]), Number(m[2])]);
expect(widths).toEqual([[362, 2], [553, 3], [1126, 6]]);
expect(css).toMatch(/\.stats-row \{\s*container-type: inline-size;/);
});
});
@@ -143,6 +143,7 @@ export function useDashboardSessions({
// Refs for fetch closures (refetch is called from various effects/handlers and should
// always see current filter values without forcing dependency-driven recreation)
const adminModeRef = useLatest(adminMode);
const joinGlobalAdminsRef = useLatest(joinGlobalAdmins);
const continuationRef = useLatest(continuation);
// Synchronous lock: set true BEFORE the first await so two triggers in the same
@@ -185,15 +186,15 @@ export function useDashboardSessions({
const hasJoinedGroup = useRef(false);
const fetchBlockedDevices = useCallback(async (currentSessions: Session[]) => {
if (!adminModeRef.current || !globalAdminModeRef.current) {
// The block list is a platform read (GlobalReadOrAdmin): a delegated admin's cross-tenant view
// would only collect 403s.
if (!adminModeRef.current || !joinGlobalAdminsRef.current) {
setBlockedDevicesSet(new Set());
return;
}
try {
const tenantIds = globalAdminModeRef.current
? [...new Set(currentSessions.map((s) => s.tenantId))]
: tenantIdRef.current ? [tenantIdRef.current] : [];
const tenantIds = [...new Set(currentSessions.map((s) => s.tenantId))];
if (tenantIds.length === 0) {
setBlockedDevicesSet(new Set());
@@ -219,7 +220,7 @@ export function useDashboardSessions({
} catch (error) {
console.error("Failed to fetch blocked devices:", error);
}
}, [getAccessToken, setBlockedDevicesSet, adminModeRef, globalAdminModeRef, tenantIdRef]);
}, [getAccessToken, setBlockedDevicesSet, adminModeRef, joinGlobalAdminsRef]);
// Internal batch fetcher — returns data without touching state so callers can
// decide how to apply it (single append vs. progressive loop). First-page calls use
@@ -1,6 +1,6 @@
"use client";
import { sessionUrl } from "@/lib/routes";
import { appsHubUrl, sessionUrl } from "@/lib/routes";
import Link from "next/link";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
@@ -10,7 +10,7 @@ import { useSignalR } from "../../contexts/SignalRContext";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { useNotifications } from "../../contexts/NotificationContext";
import { StatsCard } from "./components/StatsCards";
import { StatsCard, statsGridClassName } from "./components/StatsCards";
import { FirstEnrollmentCard, NoSessionsNote, SetupChecklistCard } from "./components/SetupChecklistCard";
import { ActivelyDevelopedBanner } from "./components/ActivelyDevelopedBanner";
import { AppHomingBanner } from "./components/AppHomingBanner";
@@ -35,10 +35,16 @@ import { useDashboardSessions } from "./hooks/useDashboardSessions";
import { useDashboardStats } from "./hooks/useDashboardStats";
import { api } from "@/lib/api";
import { formatDuration } from "@/lib/formatting";
import { hasTenantReadScope } from "@/lib/tenantScope";
import { canChangeOwnTenantSettings, hasTenantReadScope } from "@/lib/tenantScope";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import type { GetRuleHitSessionsResponse } from "@/lib/generated/wire-types.generated";
import { fetchJson } from "@/lib/apiClient";
import { trackEvent } from "@/lib/appInsights";
import type { TenantScopeSelection } from "@/lib/scopedApi";
import { vulnerabilityCardState } from "@/lib/vulnerabilityHeadline";
import { useVulnerabilityHeadline } from "@/hooks/useVulnerabilityHeadline";
import { ANALYZER_SETTING_PATH, onAnalyzerEnableClick } from "@/components/vulnerability/AnalyzerOffNotice";
import { APPS_DEFAULT_WINDOW_DAYS } from "../apps/components/types";
export default function Home() {
// useSearchParams() in HomeContent requires a Suspense boundary for static prerender.
@@ -144,7 +150,7 @@ function HomeContent() {
const {
showBlockConfirm, blockTargets, blockingDevice, blockedDevicesSet, setBlockedDevicesSet,
blockDevices, confirmBlock, cancelBlock,
} = useBlockDevice(getAccessToken, addNotification, adminMode, crossTenant);
} = useBlockDevice(getAccessToken, addNotification, adminMode, joinGlobalAdmins);
const {
sessions, loading, hasMore, loadingMore, loadingAll,
@@ -252,6 +258,32 @@ function HomeContent() {
disabled: isRegularUser,
});
// Vulnerability card: one tenant in view — the own tenant, or the drill-in of a Global Admin / Global
// Reader in global mode. Aggregates and delegated views keep five cards: the Software hub seeds its
// scope from a link only for platform admins, so a delegated click would land on another tenant.
const drillInTenantId = submittedTenantIdFilter.trim();
const vulnTenantId = !user || isRegularUser
? null
: !crossTenant
? tenantId || null
: globalAdminMode && hasGlobalScope && isGuid(drillInTenantId)
? drillInTenantId
: null;
const vulnSelection: TenantScopeSelection | null = !vulnTenantId
? null
: crossTenant
? { routeGlobal: true, selectedTenantId: vulnTenantId, effectiveTenantId: vulnTenantId }
: { routeGlobal: false, selectedTenantId: "", effectiveTenantId: vulnTenantId };
const vulnHeadline = useVulnerabilityHeadline(vulnSelection, vulnTenantId, APPS_DEFAULT_WINDOW_DAYS);
const vulnCard = vulnerabilityCardState(vulnHeadline, {
canEnable: canChangeOwnTenantSettings(user, vulnTenantId, tenantId),
days: APPS_DEFAULT_WINDOW_DAYS,
});
const vulnCardKind = vulnCard?.kind;
useEffect(() => {
if (vulnCardKind && vulnCardKind !== "loading") trackEvent("vuln_card_shown", { state: vulnCardKind });
}, [vulnCardKind]);
// 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.
@@ -485,50 +517,76 @@ function HomeContent() {
`dashboardStats === null` covers both initial load and post-scope-change
reset; show a non-zero placeholder so a fetch error doesn't masquerade
as legitimate zeros. */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5 mb-2">
<StatsCard
title="Active Sessions"
value={dashboardStats ? dashboardStats.activeCount.toString() : "..."}
description="Currently enrolling"
icon="activity"
/>
<StatsCard
title="Success Rate"
value={dashboardStats ? `${dashboardStats.successRatePct}%` : "..."}
description="Last 7 days"
icon="success"
/>
{/* Median, not mean — a few overnight/WhiteGlove outliers dominate the average
of the right-skewed duration distribution; P90 keeps the tail visible. */}
<StatsCard
title="Median Duration"
value={
dashboardStats
? dashboardStats.medianDurationMinutes > 0
? `${dashboardStats.medianDurationMinutes} min`
: "—"
: "..."
}
description={
dashboardStats && dashboardStats.p90DurationMinutes > 0
? `P90 ${formatDuration(dashboardStats.p90DurationMinutes * 60)} · last 7 days`
: "Last 7 days"
}
icon="duration"
/>
<StatsCard
title="Total Today"
value={dashboardStats ? dashboardStats.totalToday.toString() : "..."}
description="Started today"
icon="today"
/>
<StatsCard
title="Failed Today"
value={dashboardStats ? dashboardStats.failedToday.toString() : "..."}
description={dashboardStats && dashboardStats.failedToday === 0 ? "No failures" : "Needs attention"}
icon="failed"
alert={!!dashboardStats && dashboardStats.failedToday > 0}
/>
{/* The wrapper is the size container the six-card columns follow (statsGridClassName). */}
<div className="stats-row mb-2">
<div className={statsGridClassName(vulnCard ? 6 : 5)}>
<StatsCard
title="Active Sessions"
value={dashboardStats ? dashboardStats.activeCount.toString() : "..."}
description="Currently enrolling"
icon="activity"
/>
<StatsCard
title="Success Rate"
value={dashboardStats ? `${dashboardStats.successRatePct}%` : "..."}
description="Last 7 days"
icon="success"
/>
{/* Median, not mean — a few overnight/WhiteGlove outliers dominate the average
of the right-skewed duration distribution; P90 keeps the tail visible. */}
<StatsCard
title="Median Duration"
value={
dashboardStats
? dashboardStats.medianDurationMinutes > 0
? `${dashboardStats.medianDurationMinutes} min`
: "—"
: "..."
}
description={
dashboardStats && dashboardStats.p90DurationMinutes > 0
? [`P90 ${formatDuration(dashboardStats.p90DurationMinutes * 60)}`, "last 7 days"]
: "Last 7 days"
}
icon="duration"
/>
<StatsCard
title="Total Today"
value={dashboardStats ? dashboardStats.totalToday.toString() : "..."}
description="Started today"
icon="today"
/>
<StatsCard
title="Failed Today"
value={dashboardStats ? dashboardStats.failedToday.toString() : "..."}
description={dashboardStats && dashboardStats.failedToday === 0 ? "No failures" : "Needs attention"}
icon="failed"
alert={!!dashboardStats && dashboardStats.failedToday > 0}
/>
{vulnCard && (
<StatsCard
title="Vulnerabilities"
value={vulnCard.value}
description={vulnCard.parts}
icon={vulnCard.alert ? "shieldAlert" : "shield"}
alert={vulnCard.alert}
muted={vulnCard.muted}
href={
vulnCard.link === "tab"
? appsHubUrl({ tab: "vulnerabilities", tenantId: crossTenant ? vulnTenantId ?? undefined : undefined })
: vulnCard.link === "settings"
? ANALYZER_SETTING_PATH
: undefined
}
linkOn={vulnCard.link === "settings" ? "description" : "title"}
onClick={() =>
vulnCard.link === "settings"
? onAnalyzerEnableClick("dashboard_card")
: trackEvent("vuln_link_clicked", { from: "dashboard_card", state: vulnCard.kind })
}
/>
)}
</div>
</div>
<TipOfTheDay />
@@ -627,6 +685,7 @@ function HomeContent() {
blockedDevicesSet={blockedDevicesSet}
isActivationPending={isActivationPending}
user={user}
ownTenantId={tenantId}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
onDeleteSessions={deleteSessions}
@@ -1027,3 +1027,27 @@ html.auth-pending .landing-v2::after {
.dark .setup-tour-spot {
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.72);
}
/* Dashboard stat row with six cards (app/dashboard/components/StatsCards.tsx, statsGridClassName).
The columns follow the width the row itself gets, so the sidebar state and the full-width layout
count: six from 1126px (a 1440px window with the full sidebar), three from 553px, two from 362px,
below those a card title would be cut off. Plain CSS because the ascending order is the cascade:
Tailwind sorts arbitrary @container variants as text (1126 before 362), and the three-column rule
then won at every width. StatsCards.test.ts holds the order. */
.stats-row {
container-type: inline-size;
}
.stats-grid-6 {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(1, minmax(0, 1fr));
}
@container (min-width: 362px) {
.stats-grid-6 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@container (min-width: 553px) {
.stats-grid-6 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@container (min-width: 1126px) {
.stats-grid-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
@@ -1,6 +1,6 @@
"use client";
import { createContext, useContext, useState, useMemo, useDeferredValue } from "react";
import { createContext, useContext, useState, useMemo, useDeferredValue, useEffect, useRef } from "react";
import { EnrollmentEvent, Session } from "@/types";
import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../lib/eventHelpers";
import { buildEventSearchMatcher, formatEventSearchTerm, parseEventSearchQuery } from "../lib/eventSearchQuery";
@@ -10,6 +10,7 @@ import { readTimeProvenance, classifyTimeJump, readClockChangeDeltaMs } from "@/
import { isRebootOrRetryClass } from "@/lib/installProgress";
import { formatDuration, formatUtcOffset } from "@/lib/formatting";
import { SCRIPT_OUTPUT_RECONCILIATION } from "@/lib/scriptExecutions";
import { isTypingInField } from "@/lib/keyboardTarget";
// runId → outcome of every platform run that IME's saved result corrected later (D-316): the
// run's own row says so, so its emitted output is never read as the script's.
@@ -19,7 +20,8 @@ const SEARCH_SYNTAX_HINT =
"Searches event type, message, source and the details JSON (e.g. gather output). " +
"Several terms are combined with AND; quote a phrase, e.g. \"Installation completed\". " +
"A leading minus hides matches, e.g. -app_install_progress. " +
"Restrict a term to one field with type=, message=, source= or data=, e.g. data=hpia-log-collect.";
"Restrict a term to one field with type=, message=, source= or data=, e.g. data=hpia-log-collect. " +
"Press / to jump here from anywhere on the page; Esc clears the search.";
interface EventTimelineProps {
filteredEvents: EnrollmentEvent[];
@@ -76,6 +78,26 @@ export default function EventTimeline({
// The input stays on the immediate value; everything derived from the query (matcher, counter,
// visible rows) follows the deferred one, so a keystroke never waits for the per-phase filter pass.
const deferredSearchQuery = useDeferredValue(searchQuery);
const searchRowRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// "/" jumps to the search from anywhere on the page, the web's usual key for a page's own search,
// the same on Windows and Mac: the controls row scrolls up below the navbar, so the matches fill
// the screen, and the previous query is selected for overtyping. Shift stays allowed, layouts
// like German type "/" as Shift+7; a "/" typed into a field belongs to that field.
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey || e.isComposing || isTypingInField(e.target)) return;
const input = searchInputRef.current;
if (!input) return;
e.preventDefault();
input.focus({ preventScroll: true });
input.select();
searchRowRef.current?.scrollIntoView({ block: "start" });
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// Terms are AND-ed, a leading minus excludes, key=value restricts — see lib/eventSearchQuery.ts.
const matchesSearch = useMemo(() => buildEventSearchMatcher(deferredSearchQuery), [deferredSearchQuery]);
@@ -126,20 +148,34 @@ export default function EventTimeline({
const timeline = (
<div className="space-y-6">
{/* Search + Severity filters + Expand/Collapse — shared controls above the timeline(s) */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<div ref={searchRowRef} className="flex flex-col sm:flex-row sm:items-center gap-2 scroll-mt-20">
{/* Search bar — full width on mobile, fixed width on desktop */}
<div className="relative w-full sm:w-48 flex-shrink-0">
<svg className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
// Esc clears the query and keeps the cursor in the box for the next term; Esc on an
// empty box leaves it. During IME composition Esc cancels the composition instead.
onKeyDown={(e) => {
if (e.key !== "Escape" || e.nativeEvent.isComposing) return;
if (searchQuery) setSearchQuery("");
else e.currentTarget.blur();
}}
placeholder="Search or -exclude..."
title={SEARCH_SYNTAX_HINT}
className="w-full pl-7 pr-7 py-1 text-xs border border-gray-300 rounded-full focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-green-500"
/>
{/* Shortcut hint while the box is empty; phones have no hardware keyboard, so it stays off there. */}
{!searchQuery && (
<kbd className="hidden sm:inline absolute right-2.5 top-1/2 -translate-y-1/2 px-1.5 rounded border border-gray-200 bg-gray-50 font-sans text-[10px] leading-4 text-gray-400 pointer-events-none">
/
</kbd>
)}
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
@@ -1,7 +1,10 @@
"use client";
import { useState, useMemo } from "react";
import type { Route } from "next";
import Link from "next/link";
import type { EnrollmentEvent } from "@/types";
import { trackEvent } from "@/lib/appInsights";
import { CONTEXT_PILL_CLASS, formatEpss, epssTitle, priorityLabel, PRIORITY_TITLE } from "@/lib/vulnerabilitySeverity";
interface VulnerabilityReportSectionProps {
@@ -10,6 +13,8 @@ interface VulnerabilityReportSectionProps {
expanded: boolean;
setExpanded: (v: boolean) => void;
onRescan?: () => Promise<void> | void;
/** Software › Vulnerabilities in the session's tenant; absent where the hub cannot open that tenant. */
fleetHref?: Route;
}
type TabKey = "findings" | "inventory" | "delta";
@@ -128,6 +133,7 @@ export default function VulnerabilityReportSection({
expanded,
setExpanded,
onRescan,
fleetHref,
}: VulnerabilityReportSectionProps) {
const [activeTab, setActiveTab] = useState<TabKey>("findings");
const [rescanning, setRescanning] = useState(false);
@@ -446,7 +452,10 @@ export default function VulnerabilityReportSection({
<div className="bg-white shadow rounded-lg p-6 mb-6">
{/* Section Header */}
<div
onClick={() => setExpanded(!expanded)}
onClick={() => {
trackEvent("session_vuln_report_action", { action: expanded ? "collapse" : "expand" });
setExpanded(!expanded);
}}
className="flex items-start justify-between gap-2 w-full text-left mb-4 cursor-pointer"
>
<div className="flex items-center flex-wrap gap-x-2 gap-y-1 min-w-0">
@@ -490,10 +499,24 @@ export default function VulnerabilityReportSection({
)}
</div>
<div className="flex items-center space-x-3 shrink-0">
{fleetHref && (
<Link
href={fleetHref}
onClick={(e) => {
// The header toggles the section; the link only navigates.
e.stopPropagation();
trackEvent("vuln_link_clicked", { from: "session_report" });
}}
className="hidden sm:inline text-sm font-medium text-green-700 hover:underline"
>
Fleet-wide exposure →
</Link>
)}
{onRescan && (
<button
onClick={async (e) => {
e.stopPropagation();
trackEvent("session_vuln_report_action", { action: "rescan" });
setRescanning(true);
try { await onRescan(); }
finally { setRescanning(false); }
@@ -1,6 +1,6 @@
"use client";
import { diagnosisUrl } from "@/lib/routes";
import { appsHubUrl, diagnosisUrl } from "@/lib/routes";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
@@ -82,7 +82,7 @@ function SessionDetailForCurrentId() {
function SessionDetailContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { getAccessToken, user } = useAuth();
const { getAccessToken, user, hasGlobalScope } = useAuth();
// Query-string identity (`?id=`) — path params are gone with the static export;
// legacy /sessions/{id} URLs are rewritten here by LegacyPathRedirect.
// The raw value is attacker-deliverable (deep link) and useSearchParams returns it
@@ -167,14 +167,22 @@ function SessionDetailContent() {
// Cross-tenant read-only view: a delegated ("MSP") admin viewing a MANAGED tenant's session. The backend
// permits the reads (MemberRead + ?tenantId=, rescued by the delegated scope) but rejects the mutations
// (mark-failed/succeeded/report are TenantAdminOrGA). A Global Admin keeps write rights cross-tenant, and
// own-tenant viewers are unaffected. `tenantIdOverride` seeds this before the session object has loaded so
// the write controls never flash in.
// (mark-failed/succeeded are TenantAdminOrGA, report and actions TenantAdminOrOperator). A Global Admin keeps
// write rights cross-tenant, and own-tenant viewers are unaffected. `tenantIdOverride` seeds this before the
// session object has loaded so the write controls never flash in. Succeed/Fail additionally need Admin Mode,
// which takes effect only for a tenant Admin or Global Admin (useAdminMode).
const viewedTenantId = (session?.tenantId ?? sessionTenantId ?? tenantIdOverride ?? "").toLowerCase();
const isCrossTenantView = !!viewedTenantId && !!tenantId && viewedTenantId !== tenantId.toLowerCase();
const isReadOnlyView = isCrossTenantView && !user?.isGlobalAdmin;
// Report Session is an Admin/Operator route (Global Admins too); Viewers get no button that ends in a 403.
const canReportSession = (user?.isTenantAdmin ?? false) || user?.role === "Operator" || (user?.isGlobalAdmin ?? false);
// The Vulnerability Report's way to the fleet view: the own tenant, or a platform admin's jump into the
// session's tenant. None for a delegated view — the Software hub takes no tenant from a link for them.
const vulnFleetHref = !isCrossTenantView
? appsHubUrl({ tab: "vulnerabilities" })
: hasGlobalScope
? appsHubUrl({ tab: "vulnerabilities", tenantId: viewedTenantId })
: undefined;
// Browser-tab title: lead with the device identifier so multiple session tabs stay distinguishable when
// compared side by side (tabs truncate to the first chars). Pipe separator matches the root layout
@@ -285,27 +293,24 @@ function SessionDetailContent() {
const cancelMarkFailed = () => setShowMarkFailedConfirm(false);
const cancelMarkSucceeded = () => setShowMarkSucceededConfirm(false);
const confirmMarkFailed = async () => {
const markSession = async (outcome: 'Failed' | 'Succeeded') => {
const effectiveTenantId = sessionTenantId || tenantId;
const url = outcome === 'Failed'
? api.sessions.markFailed(sessionId, effectiveTenantId)
: api.sessions.markSucceeded(sessionId, effectiveTenantId);
try {
await fetchOk(api.sessions.markFailed(sessionId, effectiveTenantId), getAccessToken, { method: 'POST' });
setShowMarkFailedConfirm(false);
if (session) setSession({ ...session, status: 'Failed' });
await fetchOk(url, getAccessToken, { method: 'POST' });
if (session) setSession({ ...session, status: outcome });
} catch (error) {
console.error('Error marking session as failed:', error);
}
};
const confirmMarkSucceeded = async () => {
const effectiveTenantId = sessionTenantId || tenantId;
try {
await fetchOk(api.sessions.markSucceeded(sessionId, effectiveTenantId), getAccessToken, { method: 'POST' });
setShowMarkSucceededConfirm(false);
if (session) setSession({ ...session, status: 'Succeeded' });
} catch (error) {
console.error('Error marking session as succeeded:', error);
const verb = outcome.toLowerCase();
notifyError(`Mark as ${verb}`, error, 'mark-session-error', `The session could not be marked as ${verb}.`);
} finally {
if (outcome === 'Failed') setShowMarkFailedConfirm(false);
else setShowMarkSucceededConfirm(false);
}
};
const confirmMarkFailed = () => markSession('Failed');
const confirmMarkSucceeded = () => markSession('Succeeded');
const handleSubmitReport = async (submission: ReportSubmission) => {
const effectiveTenantId = sessionTenantId || tenantId;
@@ -751,6 +756,7 @@ function SessionDetailContent() {
expanded={vulnerabilityReportExpanded}
setExpanded={setVulnerabilityReportExpanded}
onRescan={isReadOnlyView ? undefined : () => fetchVulnerabilityReport(true)}
fleetHref={vulnFleetHref}
/>
</div>
)}
@@ -44,24 +44,38 @@ function CloseButton() {
);
}
const SHOW_ME: Record<Exclude<SetupTour, "validation">, { anchors: readonly string[]; title: string; crumb: string; text: string }> = {
/** `fromGuide`: opened by the guide's "Show me where", so the card says so and leads back to it. */
const SHOW_ME: Record<
Exclude<SetupTour, "validation">,
{ anchors: readonly string[]; title: string; crumb: string; text: string; fromGuide: boolean }
> = {
"showme:timezone": {
anchors: ["timezone", "geo-location"],
title: "Set Timezone Automatically",
crumb: "Agent › Agent Settings › Agent Parameters",
text: "The guide turns it on if you keep it checked. You can switch it here at any time. It appears once Geo-Location Detection is on.",
fromGuide: true,
},
"showme:diagnostics": {
anchors: ["diagnostics-upload"],
title: "Upload destination",
crumb: "Agent › Diagnostics Package",
text: "The guide picks hosted storage here, or keeps your own storage account. Upload Mode, further down on this page, sets when a package is uploaded. You can change both at any time.",
fromGuide: true,
},
"showme:inventory": {
anchors: ["software-inventory"],
title: "Software Inventory & Vulnerability Analyzer",
crumb: "Agent › Agent Analyzers",
text: "The guide turns it on. Its findings show up in each session and under Software.",
fromGuide: true,
},
"spot:inventory": {
anchors: ["software-inventory"],
title: "Software Inventory & Vulnerability Analyzer",
crumb: "Agent › Agent Analyzers",
text: "Turn it on here and save. Enrollments are scanned from then on; findings appear in each session and under Software › Vulnerabilities.",
fromGuide: false,
},
};
@@ -72,14 +86,18 @@ function ShowMeTour({ tour }: { tour: Exclude<SetupTour, "validation"> }) {
openSetupGuide("showme", "settings");
};
return (
<TourSpotlight anchors={text.anchors} label="Setup guide hint" onClose={endSetupTour}>
<TourSpotlight anchors={text.anchors} label={text.fromGuide ? "Setup guide hint" : "Settings hint"} onClose={endSetupTour}>
<div className="space-y-2">
<p className={eyebrow}>Setup guide · Where to find it<CloseButton /></p>
<p className={eyebrow}>{text.fromGuide ? "Setup guide · Where to find it" : "Where to find it"}<CloseButton /></p>
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">{text.title}</h3>
<p className="text-xs text-gray-400">{text.crumb}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{text.text}</p>
<div className="pt-1">
<button type="button" className={primaryButton} onClick={back}>Back to the guide</button>
{text.fromGuide ? (
<button type="button" className={primaryButton} onClick={back}>Back to the guide</button>
) : (
<button type="button" className={primaryButton} onClick={endSetupTour}>Got it</button>
)}
</div>
</div>
</TourSpotlight>
@@ -15,6 +15,7 @@ import { UserAvatar } from './UserAvatar';
import { trackEvent } from '@/lib/appInsights';
import { useAdminMode } from '@/hooks/useAdminMode';
import { useEditionInfo } from '@/hooks/useEditionInfo';
import { canUseAdminMode } from '@/lib/tenantScope';
import GlobalSearch from './GlobalSearch';
import { DOCS_URL } from "@/lib/config";
import { DOCS_PATHS } from "@/lib/docsPaths";
@@ -137,10 +138,12 @@ export default function Navbar() {
const isOperator = user?.role === 'Operator';
const isViewer = user?.role === 'Viewer';
const isAdminOrOperator = isTenantAdmin || isOperator;
// The Admin Mode toggle shows for exactly the users in whose hands it takes effect (useAdminMode).
const canToggleAdminMode = canUseAdminMode(user);
// Everything the settings menu can hold. With no toggle applicable — an Operator/Viewer, or a
// Global Admin presenting in demo mode without an own tenant-admin role — the menu would open
// onto an empty panel, so the gear (and the mobile "Administration" block) stays hidden.
const showAdminToggles = isTenantAdmin || (hasGlobalScope && !demoMode);
// Global Reader presenting in demo mode — the menu would open onto an empty panel, so the gear
// (and the mobile "Administration" block) stays hidden.
const showAdminToggles = canToggleAdminMode || (hasGlobalScope && !demoMode);
// Tenant notification dismiss is currently tenant-shared (clearing for one user clears for
// all). Only Admins / Global Admins are permitted to dismiss; Operators and Viewers see the
// bell as read-only. See TenantNotificationContext + EndpointAccessPolicyCatalog.
@@ -442,7 +445,7 @@ export default function Navbar() {
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-[32rem] overflow-y-auto">
<div className="p-3">
{/* MODE TOGGLES */}
{isTenantAdmin && (
{canToggleAdminMode && (
<>
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 mb-2">Administration</p>
@@ -695,7 +698,7 @@ export default function Navbar() {
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 mb-2">Administration</p>
{/* Admin Mode Toggle */}
{isTenantAdmin && (
{canToggleAdminMode && (
<div className="flex items-center justify-between py-2 px-2.5 rounded-md bg-gray-50 dark:bg-gray-700 mb-1">
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import type { CveExposureSummary } from "@/lib/generated/wire-types.generated";
import { toggleFilter, type ExposureFilter } from "@/lib/vulnerabilityExposure";
import { isFilterActive, toggleFilter, type ExposureAction, type ExposureFilter } from "@/lib/vulnerabilityExposure";
import { CalculatingDialog } from "@/components/CalculatingCard";
import ExposureKpiRow from "@/components/vulnerability/ExposureKpiRow";
import SeverityPriorityCard from "@/components/vulnerability/SeverityPriorityCard";
@@ -22,6 +22,10 @@ interface Props {
title?: string;
/** Elapsed/estimate of the running fetch (useFetchProgress); without it the loading state is plain text. */
progress?: { elapsedMs: number; estimateMs: number };
/** Replaces the default "no findings" text, e.g. when scanning is off for the tenant. */
emptyState?: ReactNode;
/** Each filter, lens, sort or expand step the viewer takes (telemetry of the tenant-facing tab). */
onAction?: (action: ExposureAction) => void;
}
/**
@@ -41,8 +45,18 @@ export default function VulnerabilityExposurePanel({
refreshing = false,
title,
progress,
emptyState,
onAction,
}: Props) {
const [filter, setFilter] = useState<ExposureFilter>(null);
const toggle = (kind: "severity" | "priority", value: string) => {
onAction?.({ action: "filter", value: `${kind}:${value}`, active: !isFilterActive(filter, kind, value) });
setFilter((prev) => toggleFilter(prev, kind, value));
};
const clearFilter = () => {
onAction?.({ action: "clear_filter" });
setFilter(null);
};
const hasFindings = summary != null && summary.distinctCves > 0;
const showHeader = title != null || onDaysChange != null || onRefresh != null;
const progressPercent = progress ? Math.min(95, (progress.elapsedMs / progress.estimateMs) * 100) : 0;
@@ -69,7 +83,7 @@ export default function VulnerabilityExposurePanel({
</div>
);
} else if (!hasFindings) {
body = (
body = emptyState ?? (
<div className="bg-white rounded-lg shadow p-8 text-center text-sm text-gray-500">
No vulnerability findings in this window. An empty result means no detected CVEs; it does not by itself confirm
the fleet is unaffected, because vulnerability scanning must be enabled.
@@ -99,12 +113,8 @@ export default function VulnerabilityExposurePanel({
<ExposureKpiRow summary={summary!} showTenantCount={showTenantCount} />
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-5">
<SeverityPriorityCard
summary={summary!}
filter={filter}
onToggle={(kind, value) => setFilter((prev) => toggleFilter(prev, kind, value))}
/>
<MostExposedCard summary={summary!} filter={filter} onClearFilter={() => setFilter(null)} />
<SeverityPriorityCard summary={summary!} filter={filter} onToggle={toggle} />
<MostExposedCard summary={summary!} filter={filter} onClearFilter={clearFilter} onAction={onAction} />
</div>
<p className="text-xs text-gray-500">
@@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { trackEvent } from "@/lib/appInsights";
import { SETUP_TOUR_TARGETS } from "@/lib/setupGuide";
import { startSetupTour } from "@/lib/setupGuideStore";
/** Where a "turn on" link was clicked — the `from` of `vuln_enable_clicked`. */
export type AnalyzerEnableSource = "dashboard_card" | "vulnerabilities_tab" | "inventory_tab";
/** The analyzer setting with its spotlight (app/settings/SetupGuideTour.tsx, tour `spot:inventory`). */
export const ANALYZER_SETTING_PATH = SETUP_TOUR_TARGETS["spot:inventory"].path;
/** Starts the spotlight on the analyzer switch and counts the click; the link itself navigates. */
export function onAnalyzerEnableClick(from: AnalyzerEnableSource): void {
startSetupTour("spot:inventory");
trackEvent("vuln_enable_clicked", { from });
}
const TITLES = {
vulnerabilities: "Vulnerability scanning is off for this tenant",
inventory: "Software inventory is off for this tenant",
} as const;
/**
* Empty Software › Vulnerabilities or Inventory tab of a tenant whose Software Inventory & Vulnerability
* Analyzer is off: says so and leads to the switch. Only `canEnable` users get the link (the Settings
* pages save the own tenant through TenantAdminOrGA); everyone else learns who can turn it on.
*/
export function AnalyzerOffNotice({
subject,
canEnable,
}: {
subject: keyof typeof TITLES;
canEnable: boolean;
}) {
const from: AnalyzerEnableSource = subject === "inventory" ? "inventory_tab" : "vulnerabilities_tab";
return (
<div className="bg-white rounded-lg shadow px-6 py-10 text-center">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="mx-auto h-8 w-8 text-gray-400"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 21.5s7.5-3.6 7.5-9.4V5.4L12 2.5 4.5 5.4v6.7c0 5.8 7.5 9.4 7.5 9.4z" />
</svg>
<h3 className="mt-3 text-base font-semibold text-gray-900">{TITLES[subject]}</h3>
<p className="mx-auto mt-1 max-w-xl text-sm text-gray-500">
The Software Inventory &amp; Vulnerability Analyzer lists the software installed at enrollment start and end
and matches it against known vulnerabilities (NVD, CISA KEV, MSRC). Enrollments are scanned from the moment
it is on.
</p>
{canEnable ? (
<Link
href={ANALYZER_SETTING_PATH}
onClick={() => onAnalyzerEnableClick(from)}
className="mt-5 inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2"
>
Turn on in Settings
</Link>
) : (
<p className="mt-4 text-sm text-gray-500">
A tenant admin can turn it on under Settings › Agent › Agent Analyzers.
</p>
)}
</div>
);
}
@@ -11,6 +11,7 @@ import {
listPool,
matchesFilter,
sortExposure,
type ExposureAction,
type ExposureFilter,
type ExposureSort,
} from "@/lib/vulnerabilityExposure";
@@ -54,10 +55,13 @@ export default function MostExposedCard({
summary,
filter,
onClearFilter,
onAction,
}: {
summary: CveExposureSummary;
filter: ExposureFilter;
onClearFilter: () => void;
/** Lens, sort and expand steps, for telemetry (an expand reports the product's priority, never its name). */
onAction?: (action: ExposureAction) => void;
}) {
const [lens, setLens] = useState<Lens>("software");
const [sort, setSort] = useState<ExposureSort>("priority");
@@ -83,13 +87,15 @@ export default function MostExposedCard({
const label = filterLabel(filter);
const inBand = bandCount(summary, filter);
const toggle = (product: string) =>
const toggle = (item: TopSoftware) => {
if (!expanded.has(item.product)) onAction?.({ action: "expand", value: (item.priority || "unknown").toLowerCase() });
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(product)) next.delete(product);
else next.add(product);
if (next.has(item.product)) next.delete(item.product);
else next.add(item.product);
return next;
});
};
return (
<section aria-label="Most exposed" className="bg-white rounded-lg shadow min-w-0 lg:col-span-3">
@@ -107,9 +113,19 @@ export default function MostExposedCard({
{ value: "cves", label: `CVEs (${cves.length})` },
]}
value={lens}
onChange={(v) => setLens(v as Lens)}
onChange={(v) => {
onAction?.({ action: "lens", value: v });
setLens(v as Lens);
}}
/>
<SegmentedControl
options={SORT_OPTIONS}
value={sort}
onChange={(v) => {
onAction?.({ action: "sort", value: v });
setSort(v as ExposureSort);
}}
/>
<SegmentedControl options={SORT_OPTIONS} value={sort} onChange={(v) => setSort(v as ExposureSort)} />
</div>
</div>
@@ -148,7 +164,7 @@ export default function MostExposedCard({
item={s}
max={maxSoftware}
open={expanded.has(s.product)}
onToggle={() => toggle(s.product)}
onToggle={() => toggle(s)}
/>
))}
</ul>
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { AnalyzerOffNotice, ANALYZER_SETTING_PATH } from "../AnalyzerOffNotice";
const render = (subject: "vulnerabilities" | "inventory", canEnable: boolean) =>
renderToStaticMarkup(createElement(AnalyzerOffNotice, { subject, canEnable }));
describe("AnalyzerOffNotice", () => {
it("names what is off for each tab", () => {
expect(render("vulnerabilities", false)).toContain("Vulnerability scanning is off for this tenant");
expect(render("inventory", false)).toContain("Software inventory is off for this tenant");
});
it("leads to the analyzer setting only who can turn it on", () => {
expect(ANALYZER_SETTING_PATH).toBe("/settings/agent/analyzers");
const admin = render("vulnerabilities", true);
expect(admin).toContain(`href="${ANALYZER_SETTING_PATH}"`);
expect(admin).toContain("Turn on in Settings");
const member = render("vulnerabilities", false);
expect(member).not.toContain("<a ");
expect(member).toContain("A tenant admin can turn it on");
});
});
@@ -1,12 +1,14 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/contexts/AuthContext";
import {
DEMO_MODE_STORAGE_KEY,
effectiveGlobalAdminMode,
readDemoParam,
stripDemoParam,
} from "@/lib/demoMode";
import { canUseAdminMode } from "@/lib/tenantScope";
import { readViewAs } from "@/lib/viewAs";
interface UseAdminModeReturn {
@@ -144,9 +146,15 @@ export function useAdminMode(): UseAdminModeReturn {
// write would otherwise reach every other tab through the storage event. Fixed for the page's life.
const forcedOff = demoMode || readViewAs() !== null;
// The stored flag is shared by every account in this browser. It takes effect only for a role whose
// backend tier admits the Admin Mode actions; the stored value stays, so the admin account that set it
// finds it unchanged.
const { user } = useAuth();
const adminModeAllowed = canUseAdminMode(user);
return {
adminMode,
setAdminMode: setAdminModeState,
adminMode: adminMode && adminModeAllowed,
setAdminMode: adminModeAllowed ? setAdminModeState : () => {},
globalAdminMode: effectiveGlobalAdminMode(globalAdminMode, forcedOff),
// No-op while presenting or viewing as a tenant. The toggle is hidden then anyway; this keeps any
// other caller from re-arming the global view behind the operator's back.
@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import { fetchJson } from "@/lib/apiClient";
import { cachedAuthFetchJson, FEATURE_FLAGS_TTL_MS } from "@/lib/cachedAuthFetch";
import type { CveExposureSummary, TenantFeatureFlagsResponse } from "@/lib/generated/wire-types.generated";
import { scopedApi, type TenantScopeSelection } from "@/lib/scopedApi";
import { headlineFromSummary, type VulnerabilityHeadline } from "@/lib/vulnerabilityHeadline";
/**
* One tenant's analyzer switch and vulnerability headline counts, for the dashboard card and the
* Software hub's tab badge. `sel` routes the counts like every Software-hub read; `tenantId` names the
* tenant whose feature flags say whether scanning is on. Pass null for either while no single tenant
* is in view (an aggregate, or a scope still resolving): nothing is read, the cross-tenant scan is the
* slow path and a per-tenant switch means nothing there.
*
* The counts come uncached (the tenant route answers in ~16 ms; lib/cachedAuthFetch is for /api/config
* reads only), the flags through the shared feature-flags cache like every other reader of them.
*/
export function useVulnerabilityHeadline(
sel: TenantScopeSelection | null,
tenantId: string | null,
days: number,
): VulnerabilityHeadline {
const { getAccessToken } = useAuth();
const routeGlobal = sel?.routeGlobal ?? false;
const selectedTenantId = sel?.selectedTenantId ?? "";
const effectiveTenantId = sel?.effectiveTenantId ?? "";
const key = sel && tenantId ? `${routeGlobal ? "g" : "t"}|${selectedTenantId}|${effectiveTenantId}|${tenantId}|${days}` : null;
const [result, setResult] = useState<{ key: string; headline: VulnerabilityHeadline } | null>(null);
useEffect(() => {
if (!key || !tenantId) return;
let cancelled = false;
const selection: TenantScopeSelection = { routeGlobal, selectedTenantId, effectiveTenantId };
const run = async () => {
let headline: VulnerabilityHeadline;
try {
const [flags, summary] = await Promise.all([
cachedAuthFetchJson<TenantFeatureFlagsResponse>(api.config.featureFlags(tenantId), getAccessToken, { ttlMs: FEATURE_FLAGS_TTL_MS }),
fetchJson<CveExposureSummary>(scopedApi.vulnerability(selection, days, 1), getAccessToken),
]);
headline = headlineFromSummary(flags.enableSoftwareInventoryAnalyzer ?? false, summary);
} catch {
// Fail soft: the card shows "not available", the badge stays away; the pages say why they failed.
headline = { status: "error" };
}
if (!cancelled) setResult({ key, headline });
};
void run();
return () => { cancelled = true; };
}, [key, routeGlobal, selectedTenantId, effectiveTenantId, tenantId, days, getAccessToken]);
if (!key) return { status: "skipped" };
return result?.key === key ? result.headline : { status: "loading" };
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { appsHubUrl } from "../routes";
describe("appsHubUrl", () => {
it("is the bare hub by default", () => {
expect(appsHubUrl()).toBe("/apps");
});
it("carries a tab, a window and a tenant", () => {
expect(appsHubUrl({ tab: "vulnerabilities" })).toBe("/apps?tab=vulnerabilities");
expect(appsHubUrl({ days: 90 })).toBe("/apps?days=90");
expect(appsHubUrl({ tab: "vulnerabilities", days: 7, tenantId: "11111111-1111-1111-1111-111111111111" })).toBe(
"/apps?tab=vulnerabilities&days=7&tenantId=11111111-1111-1111-1111-111111111111",
);
});
it("leaves out an empty tenant", () => {
expect(appsHubUrl({ tab: "inventory", tenantId: "" })).toBe("/apps?tab=inventory");
});
});
@@ -135,7 +135,9 @@ describe("parseSetupTour", () => {
it("accepts the known tours only", () => {
expect(parseSetupTour("validation")).toBe("validation");
expect(parseSetupTour("showme:diagnostics")).toBe("showme:diagnostics");
expect(parseSetupTour("spot:inventory")).toBe("spot:inventory");
expect(parseSetupTour("showme:elsewhere")).toBeNull();
expect(parseSetupTour("spot:elsewhere")).toBeNull();
expect(parseSetupTour(null)).toBeNull();
});
});
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import {
canAdministerTenant,
canChangeOwnTenantSettings,
canManageBootstrapSessions,
canUseAdminMode,
hasOwnTenantOrPlatformRole,
hasTenantReadScope,
type TenantScopeUser,
@@ -83,6 +86,95 @@ describe("hasTenantReadScope", () => {
});
});
describe("canUseAdminMode (mirror of TenantAdminOrGA)", () => {
it("is false without a user (auth still loading) and for a roleless member", () => {
expect(canUseAdminMode(null)).toBe(false);
expect(canUseAdminMode(undefined)).toBe(false);
expect(canUseAdminMode(user())).toBe(false);
});
it("is true for the tenant Admin and a Global Admin without a tenant role", () => {
expect(canUseAdminMode(user({ isTenantAdmin: true, role: "Admin" }))).toBe(true);
expect(canUseAdminMode(user({ role: "Admin" }))).toBe(true);
expect(canUseAdminMode(user({ isGlobalAdmin: true }))).toBe(true);
});
it("is false for Operator, Viewer, Global Reader and a delegated admin", () => {
expect(canUseAdminMode(user({ role: "Operator" }))).toBe(false);
expect(canUseAdminMode(user({ role: "Operator", canManageBootstrapTokens: true }))).toBe(false);
expect(canUseAdminMode(user({ role: "Viewer" }))).toBe(false);
expect(canUseAdminMode(user({ isGlobalReader: true }))).toBe(false);
expect(canUseAdminMode(user({ isDelegated: true }))).toBe(false);
});
it("follows the emulated role in a view-as tab (no platform role there)", () => {
expect(canUseAdminMode(user({ isTenantAdmin: true, role: "Admin" }))).toBe(true);
expect(canUseAdminMode(user({ role: "Operator" }))).toBe(false);
expect(canUseAdminMode(user({ role: "Viewer" }))).toBe(false);
});
});
describe("canAdministerTenant (TenantAdminOrGA with the session's tenant)", () => {
const own = "11111111-1111-1111-1111-111111111111";
const other = "22222222-2222-2222-2222-222222222222";
it("lets a Global Admin act on any tenant", () => {
expect(canAdministerTenant(user({ isGlobalAdmin: true }), other, own)).toBe(true);
expect(canAdministerTenant(user({ isGlobalAdmin: true }), own, own)).toBe(true);
});
it("lets the tenant Admin act on the own tenant only, case-insensitively", () => {
const admin = user({ isTenantAdmin: true, role: "Admin" });
expect(canAdministerTenant(admin, own.toUpperCase(), own)).toBe(true);
expect(canAdministerTenant(admin, other, own)).toBe(false);
});
it("refuses a delegated admin or Global Reader on a foreign tenant even with an own Admin role", () => {
expect(canAdministerTenant(user({ isDelegated: true, isTenantAdmin: true, role: "Admin" }), other, own)).toBe(false);
expect(canAdministerTenant(user({ isGlobalReader: true, isTenantAdmin: true, role: "Admin" }), other, own)).toBe(false);
});
it("refuses Operator, Viewer, a missing user and unknown tenants", () => {
expect(canAdministerTenant(user({ role: "Operator" }), own, own)).toBe(false);
expect(canAdministerTenant(user({ role: "Viewer" }), own, own)).toBe(false);
expect(canAdministerTenant(null, own, own)).toBe(false);
expect(canAdministerTenant(user({ isTenantAdmin: true }), "", own)).toBe(false);
expect(canAdministerTenant(user({ isTenantAdmin: true }), own, null)).toBe(false);
});
});
describe("canChangeOwnTenantSettings (Settings pages: own tenant, TenantAdminOrGA, no view-as)", () => {
const own = "11111111-1111-1111-1111-111111111111";
const other = "22222222-2222-2222-2222-222222222222";
it("lets the tenant Admin and a Global Admin change their own tenant, case-insensitively", () => {
expect(canChangeOwnTenantSettings(user({ isTenantAdmin: true, role: "Admin" }), own.toUpperCase(), own)).toBe(true);
expect(canChangeOwnTenantSettings(user({ role: "Admin" }), own, own)).toBe(true);
expect(canChangeOwnTenantSettings(user({ isGlobalAdmin: true }), own, own)).toBe(true);
});
it("refuses every foreign tenant, a Global Admin's too: the Settings pages would edit the home tenant", () => {
expect(canChangeOwnTenantSettings(user({ isGlobalAdmin: true }), other, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isTenantAdmin: true, role: "Admin" }), other, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isDelegated: true, isTenantAdmin: true, role: "Admin" }), other, own)).toBe(false);
});
it("lets a delegated admin with an own Admin role change the own tenant", () => {
expect(canChangeOwnTenantSettings(user({ isDelegated: true, isTenantAdmin: true, role: "Admin" }), own, own)).toBe(true);
});
it("refuses Operator, Viewer, Global Reader, a view-as tab and missing input", () => {
expect(canChangeOwnTenantSettings(user({ role: "Operator" }), own, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ role: "Viewer" }), own, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isGlobalReader: true }), own, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isTenantAdmin: true, role: "Admin", viewAs: { homeTenantId: other } }), own, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ role: "Operator", viewAs: { homeTenantId: other } }), own, own)).toBe(false);
expect(canChangeOwnTenantSettings(null, own, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isTenantAdmin: true }), null, own)).toBe(false);
expect(canChangeOwnTenantSettings(user({ isTenantAdmin: true }), own, "")).toBe(false);
});
});
describe("canManageBootstrapSessions (mirror of BootstrapManagerOrGA)", () => {
it("is false for null/undefined and a roleless user", () => {
expect(canManageBootstrapSessions(null)).toBe(false);
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import type { CveExposureSummary } from "@/lib/generated/wire-types.generated";
import { headlineFromSummary, vulnerabilityCardState, type VulnerabilityHeadline } from "../vulnerabilityHeadline";
const ready = (overrides: Partial<Extract<VulnerabilityHeadline, { status: "ready" }>> = {}): VulnerabilityHeadline => ({
status: "ready",
analyzerOn: true,
kev: 0,
kevDevices: 0,
highRisk: 0,
cves: 0,
...overrides,
});
const card = (headline: VulnerabilityHeadline, canEnable = false) => vulnerabilityCardState(headline, { canEnable, days: 30 });
describe("headlineFromSummary", () => {
it("takes the tab's known-exploited count, not the act band", () => {
const summary = {
kevCves: 3,
kevAffectedSessions: 41,
distinctCves: 283,
priorityBreakdown: { act: 2, attend: 12, track: 269 },
} as unknown as CveExposureSummary;
expect(headlineFromSummary(false, summary)).toEqual({
status: "ready",
analyzerOn: false,
kev: 3,
kevDevices: 41,
highRisk: 12,
cves: 283,
});
});
});
describe("vulnerabilityCardState", () => {
it("shows nothing while no single tenant is in view", () => {
expect(card({ status: "skipped" })).toBeNull();
});
it("holds the place while loading and says when the numbers are unavailable", () => {
expect(card({ status: "loading" })).toMatchObject({ kind: "loading", value: "...", link: null, alert: false });
expect(card({ status: "error" })).toMatchObject({ kind: "error", value: "—", parts: ["Not available"], muted: true, link: null });
});
it("turns red only for known exploited CVEs and leads to the tab", () => {
expect(card(ready({ kev: 3, kevDevices: 41, highRisk: 12, cves: 283 }))).toEqual({
kind: "kev",
value: "3",
parts: ["Known exploited", "41 devices"],
alert: true,
muted: false,
link: "tab",
});
expect(card(ready({ kev: 1, kevDevices: 1, cves: 1 }))?.parts).toEqual(["Known exploited", "1 device"]);
});
it("names the high-risk band when nothing is known exploited", () => {
expect(card(ready({ highRisk: 12, cves: 40 }))).toEqual({
kind: "high_risk",
value: "0",
parts: ["Known exploited", "12 high risk"],
alert: false,
muted: false,
link: "tab",
});
});
it("counts the CVEs when only the track band is left", () => {
expect(card(ready({ cves: 7 }))).toMatchObject({ kind: "tracked", value: "0", parts: ["Known exploited", "7 CVEs"], link: "tab" });
});
it("says no CVEs were detected in the window without calling the tenant safe", () => {
expect(card(ready())).toEqual({
kind: "none",
value: "0",
parts: ["No CVEs detected", "30 days"],
alert: false,
muted: false,
link: "tab",
});
});
it("shows Off with the way to the setting only to who can turn it on", () => {
expect(card(ready({ analyzerOn: false }), true)).toEqual({
kind: "off_admin",
value: "Off",
parts: ["Turn on scanning →"],
alert: false,
muted: true,
link: "settings",
});
expect(card(ready({ analyzerOn: false }), false)).toEqual({
kind: "off_member",
value: "Off",
parts: ["Ask a tenant admin"],
alert: false,
muted: true,
link: null,
});
});
it("keeps showing findings from before the analyzer was turned off", () => {
expect(card(ready({ analyzerOn: false, kev: 2, kevDevices: 5, cves: 9 }), true)).toMatchObject({ kind: "kev", link: "tab" });
});
});
@@ -0,0 +1,6 @@
// Page-wide key handlers stay out of the way while the user types: a key pressed in a text field
// belongs to that field, not to a shortcut.
export function isTypingInField(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
}
+9 -3
View File
@@ -62,9 +62,15 @@ export function appDetailUrl(
});
}
/** The Software hub; `days` carries a window other than the hub's default. */
export function appsHubUrl(opts?: { days?: number }): Route {
return withQuery("/apps", { days: opts?.days?.toString() });
/** Software hub tabs a link can open; Installs is the default and never in the URL. */
export type AppsHubTab = "inventory" | "vulnerabilities";
/**
* The Software hub. `days` carries a window other than the hub's default. `tenantId` opens a Global
* Admin on that tenant (the hub seeds its scope from it on first load); everyone else keeps their scope.
*/
export function appsHubUrl(opts?: { tab?: AppsHubTab; days?: number; tenantId?: string }): Route {
return withQuery("/apps", { tab: opts?.tab, days: opts?.days?.toString(), tenantId: opts?.tenantId });
}
export function dashboardUrl(opts?: {
@@ -150,7 +150,11 @@ export const AUTOPILOT_DEVICE_GROUP_RULE = '(device.devicePhysicalIds -any _ -st
// ── Spotlight tours on settings pages ────────────────────────────────────────────────────────
export const SETUP_TOURS = ["validation", "showme:timezone", "showme:diagnostics", "showme:inventory"] as const;
/**
* `showme:*` come from the setup guide and lead back to it; `spot:inventory` is the same spotlight
* opened from outside the guide (the dashboard's vulnerability card, the Software hub's notices).
*/
export const SETUP_TOURS = ["validation", "showme:timezone", "showme:diagnostics", "showme:inventory", "spot:inventory"] as const;
export type SetupTour = (typeof SETUP_TOURS)[number];
/**
@@ -162,6 +166,7 @@ export const SETUP_TOUR_TARGETS: Record<SetupTour, { path: Route }> = {
"showme:timezone": { path: route("/settings/agent/settings") },
"showme:diagnostics": { path: route("/settings/agent/diagnostics") },
"showme:inventory": { path: route("/settings/agent/analyzers") },
"spot:inventory": { path: route("/settings/agent/analyzers") },
};
export const SHOW_ME_TOUR: Record<RecommendedItem, SetupTour> = {
@@ -12,6 +12,8 @@ export interface TenantScopeUser {
isDelegated?: boolean;
role?: string | null;
canManageBootstrapTokens?: boolean;
/** Set while a Global Admin views a tenant as its member (lib/viewAs.ts); every write is refused there. */
viewAs?: object | null;
}
/** The three assignable tenant roles (mirrors Constants.TenantRoles in the backend). */
@@ -52,6 +54,48 @@ export function hasTenantReadScope(
return hasOwnTenantOrPlatformRole(user) || !!user.isDelegated;
}
/**
* True when Admin Mode may take effect for the user: the tenant Admin or a Global Admin, the roles
* the backend's TenantAdminOrGA tier admits for the actions behind it (delete, mark succeeded or
* failed). The toggle shows for exactly these users, and a stored "on" from another account in the
* same browser stays inert for everyone else.
*/
export function canUseAdminMode(user: TenantScopeUser | null | undefined): boolean {
if (!user) return false;
return !!(user.isGlobalAdmin || user.isTenantAdmin || user.role === "Admin");
}
/**
* True when the user may run Admin Mode actions on a session of `sessionTenantId`: a Global Admin
* on any tenant, the tenant Admin only on their own (`ownTenantId`). A delegated or Global Reader
* view of a foreign tenant gets no write control, because the backend refuses it there.
*/
export function canAdministerTenant(
user: TenantScopeUser | null | undefined,
sessionTenantId: string | null | undefined,
ownTenantId: string | null | undefined
): boolean {
if (!user) return false;
if (user.isGlobalAdmin) return true;
if (!canUseAdminMode(user) || !sessionTenantId || !ownTenantId) return false;
return sessionTenantId.toLowerCase() === ownTenantId.toLowerCase();
}
/**
* True when the user may change an agent setting of `viewedTenantId` from the portal: the Settings pages
* always edit the caller's own tenant and save through TenantAdminOrGA, and the Global Admin tenant
* editor has no agent-analyzer fields. So: the own tenant, Admin Mode roles, and never a view-as tab
* (it shows an Admin role, but the backend refuses every write there).
*/
export function canChangeOwnTenantSettings(
user: TenantScopeUser | null | undefined,
viewedTenantId: string | null | undefined,
ownTenantId: string | null | undefined
): boolean {
if (!user || user.viewAs || !viewedTenantId || !ownTenantId) return false;
return canUseAdminMode(user) && viewedTenantId.toLowerCase() === ownTenantId.toLowerCase();
}
/**
* True when the user may list and manage their OWN tenant's bootstrap sessions — the client
* mirror of the backend's BootstrapManagerOrGA tier: Global Admin, the tenant Admin, or an
@@ -12,6 +12,17 @@ export type ExposureFilter = { kind: "severity" | "priority"; value: string } |
export type ExposureSort = "priority" | "devices";
/**
* A step a viewer takes in the exposure panel, reported as `vuln_exposure_action`. `value` is a band,
* lens, sort or priority — never a CVE id or a product name.
*/
export interface ExposureAction {
action: "filter" | "clear_filter" | "lens" | "sort" | "expand";
value?: string;
/** Filter only: whether the toggle switched the band on. */
active?: boolean;
}
/** The fields a filter reads; a TopCve, a TopSoftware entry and a SoftwareCve all carry them. */
interface Banded {
cvssSeverity: string;
@@ -0,0 +1,95 @@
import type { CveExposureSummary } from "@/lib/generated/wire-types.generated";
/**
* Whether one tenant scans for vulnerabilities and its headline counts — what the dashboard card and
* the Software hub's tab badge show. `skipped` = no single tenant in view (an aggregate), nothing read.
*/
export type VulnerabilityHeadline =
| { status: "skipped" }
| { status: "loading" }
| { status: "error" }
| {
status: "ready";
analyzerOn: boolean;
/** CVEs on the CISA KEV list, the number the Vulnerabilities tab shows as "Known exploited". */
kev: number;
/** Devices (enrollments) carrying at least one of them. */
kevDevices: number;
/** The tab's "Attend" band: EPSS ≥ 10 % or CVSS ≥ 9.0. */
highRisk: number;
cves: number;
};
export function headlineFromSummary(analyzerOn: boolean, summary: CveExposureSummary): VulnerabilityHeadline {
return {
status: "ready",
analyzerOn,
kev: summary.kevCves,
kevDevices: summary.kevAffectedSessions,
highRisk: summary.priorityBreakdown.attend,
cves: summary.distinctCves,
};
}
/** Telemetry and test name of each card state (the event property `state`). */
export type VulnerabilityCardKind =
| "kev"
| "high_risk"
| "tracked"
| "none"
| "off_admin"
| "off_member"
| "loading"
| "error";
export interface VulnerabilityCardState {
kind: VulnerabilityCardKind;
value: string;
/** Description parts; the card joins them with " · " and wraps each as a whole. */
parts: string[];
/** Red value and icon: only when a CVE in the window is known to be exploited. */
alert: boolean;
/** Gray value: scanning is off, or the numbers could not be read. */
muted: boolean;
/** Where the whole card leads: the Vulnerabilities tab, the analyzer setting, or nowhere. */
link: "tab" | "settings" | null;
}
const plural = (n: number, one: string, many: string) => `${n.toLocaleString()} ${n === 1 ? one : many}`;
/**
* The dashboard card for a headline. Scanning that is off shows "Off" only while the window holds no
* findings; findings from before it was turned off are still real and lead to the tab. The link to the
* setting is for `canEnable` users only (lib/tenantScope.ts canChangeOwnTenantSettings).
*/
export function vulnerabilityCardState(
headline: VulnerabilityHeadline,
opts: { canEnable: boolean; days: number },
): VulnerabilityCardState | null {
switch (headline.status) {
case "skipped":
return null;
case "loading":
return { kind: "loading", value: "...", parts: ["Known exploited"], alert: false, muted: false, link: null };
case "error":
return { kind: "error", value: "—", parts: ["Not available"], alert: false, muted: true, link: null };
}
if (headline.cves === 0) {
if (!headline.analyzerOn) {
return opts.canEnable
? { kind: "off_admin", value: "Off", parts: ["Turn on scanning →"], alert: false, muted: true, link: "settings" }
: { kind: "off_member", value: "Off", parts: ["Ask a tenant admin"], alert: false, muted: true, link: null };
}
return { kind: "none", value: "0", parts: ["No CVEs detected", plural(opts.days, "day", "days")], alert: false, muted: false, link: "tab" };
}
const value = headline.kev.toLocaleString();
if (headline.kev > 0) {
return { kind: "kev", value, parts: ["Known exploited", plural(headline.kevDevices, "device", "devices")], alert: true, muted: false, link: "tab" };
}
if (headline.highRisk > 0) {
return { kind: "high_risk", value, parts: ["Known exploited", `${headline.highRisk.toLocaleString()} high risk`], alert: false, muted: false, link: "tab" };
}
return { kind: "tracked", value, parts: ["Known exploited", plural(headline.cves, "CVE", "CVEs")], alert: false, muted: false, link: "tab" };
}