mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-12 20:51:56 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 456b8f6025 | |||
| c3faece6d8 | |||
| f0fee75b08 | |||
| ac381a39ad | |||
| a1936b7cb6 | |||
| ba53b82c00 | |||
| 7f53dcf223 | |||
| cd2ea05878 | |||
| ccdd8c17ce |
+1
-1
Submodule .claude/commands updated: 9fb9fe058b...155a138209
+1
-1
Submodule internal updated: fea540d819...2e314b1de5
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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}");
|
||||
|
||||
+4
-2
@@ -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}");
|
||||
|
||||
+4
-2
@@ -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("")}
|
||||
|
||||
+24
-1
@@ -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 & 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>
|
||||
|
||||
+24
@@ -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";
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
Reference in New Issue
Block a user