-
+ }>
}
/>
@@ -73,7 +105,7 @@ export default function App() {
path="/users"
element={
-
+ }>
}
/>
@@ -81,7 +113,7 @@ export default function App() {
path="/audit"
element={
-
+ }>
}
/>
@@ -89,7 +121,7 @@ export default function App() {
path="/data-sources"
element={
-
+ }>
}
/>
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index d5aee29..16d841e 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -19,6 +19,7 @@ import {
Gauge,
ShieldCheck,
GitCompareArrows,
+ ScrollText,
} from "lucide-react";
import { useAuth } from "../context/AuthContext";
@@ -26,13 +27,15 @@ interface NavItem {
to: string;
label: string;
icon: React.FC<{ className?: string }>;
+ /** Roles allowed to see this item. undefined = everyone. */
+ roles?: string[];
children?: NavItem[];
}
const mainLinks: NavItem[] = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
- { to: "/techniques", label: "ATT&CK Matrix", icon: Shield },
- { to: "/matrix", label: "Advanced Heatmap", icon: Grid3X3 },
+ { to: "/executive-dashboard", label: "Executive Dashboard", icon: Gauge, roles: ["admin", "red_lead", "blue_lead"] },
+ { to: "/matrix", label: "ATT&CK Matrix", icon: Grid3X3 },
{
to: "/tests",
label: "Tests",
@@ -43,19 +46,18 @@ const mainLinks: NavItem[] = [
{ to: "/test-catalog", label: "Test Catalog", icon: BookOpen },
],
},
- { to: "/executive-dashboard", label: "Executive Dashboard", icon: Gauge },
- { to: "/reports", label: "Reports", icon: BarChart3 },
- { to: "/threat-actors", label: "Threat Actors", icon: Crosshair },
{ to: "/campaigns", label: "Campaigns", icon: Zap },
- { to: "/comparison", label: "Comparison", icon: GitCompareArrows },
+ { to: "/threat-actors", label: "Threat Actors", icon: Crosshair },
{ to: "/compliance", label: "Compliance", icon: ShieldCheck },
+ { to: "/comparison", label: "Comparison", icon: GitCompareArrows, roles: ["admin", "red_lead", "blue_lead"] },
+ { to: "/reports", label: "Reports", icon: BarChart3 },
];
-const adminLinks: NavItem[] = [
+const systemLinks: NavItem[] = [
+ { to: "/data-sources", label: "Data Sources", icon: Database },
+ { to: "/system", label: "MITRE Sync", icon: ScrollText },
{ to: "/users", label: "Users", icon: Users },
{ to: "/audit", label: "Audit Log", icon: FileText },
- { to: "/data-sources", label: "Data Sources", icon: Database },
- { to: "/system", label: "System", icon: Settings },
];
function SidebarLink({ item }: { item: NavItem }) {
@@ -117,7 +119,15 @@ function SidebarLink({ item }: { item: NavItem }) {
export default function Sidebar() {
const { user } = useAuth();
- const isAdmin = user?.role === "admin";
+ const role = user?.role ?? "";
+ const isAdmin = role === "admin";
+
+ /** Returns true when the current user is allowed to see `item`. */
+ const canSee = (item: NavItem) => {
+ if (!item.roles) return true; // no restriction
+ if (isAdmin) return true; // admin sees everything
+ return item.roles.includes(role);
+ };
return (
diff --git a/frontend/src/components/heatmap/HeatmapCell.tsx b/frontend/src/components/heatmap/HeatmapCell.tsx
index 7dfa494..66f2654 100644
--- a/frontend/src/components/heatmap/HeatmapCell.tsx
+++ b/frontend/src/components/heatmap/HeatmapCell.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import React, { useState, useMemo, useCallback } from "react";
import type { HeatmapTechnique } from "../../api/heatmap";
import HeatmapTooltip from "./HeatmapTooltip";
@@ -8,7 +8,12 @@ interface HeatmapCellProps {
onClick: (techniqueId: string) => void;
}
-export default function HeatmapCell({ technique, size, onClick }: HeatmapCellProps) {
+/**
+ * Memoized heatmap cell — this component renders 3000+ times in the
+ * full ATT&CK matrix, so React.memo prevents unnecessary re-renders
+ * when only sibling cells change.
+ */
+const HeatmapCell = React.memo(function HeatmapCell({ technique, size, onClick }: HeatmapCellProps) {
const [showTooltip, setShowTooltip] = useState(false);
const sizeClasses = {
@@ -20,21 +25,28 @@ export default function HeatmapCell({ technique, size, onClick }: HeatmapCellPro
const bgColor = technique.enabled ? technique.color : "transparent";
const isDisabled = !technique.enabled;
- // Determine text color based on background brightness
- const getTextColor = (hex: string): string => {
+ // Memoize text color (derived from background hex)
+ const textColor = useMemo(() => {
+ const hex = bgColor;
if (!hex || hex === "transparent" || hex === "") return "text-gray-600";
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness > 128 ? "text-gray-900" : "text-white";
- };
+ }, [bgColor]);
- // Status indicators
- const hasTests = technique.metadata.find((m) => m.name === "tests_count");
- const testsCount = hasTests ? parseInt(hasTests.value, 10) : 0;
- const reviewRequired = technique.comment?.toLowerCase().includes("review");
- const isValidated = technique.score >= 100;
+ // Status indicators — memoized
+ const { testsCount, reviewRequired, isValidated } = useMemo(() => {
+ const hasTests = technique.metadata.find((m) => m.name === "tests_count");
+ return {
+ testsCount: hasTests ? parseInt(hasTests.value, 10) : 0,
+ reviewRequired: technique.comment?.toLowerCase().includes("review") ?? false,
+ isValidated: technique.score >= 100,
+ };
+ }, [technique.metadata, technique.comment, technique.score]);
+
+ const handleClick = useCallback(() => onClick(technique.techniqueID), [onClick, technique.techniqueID]);
return (
setShowTooltip(false)}
>
);
-}
+});
+
+export default HeatmapCell;
diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts
new file mode 100644
index 0000000..f432d94
--- /dev/null
+++ b/frontend/src/hooks/useDebounce.ts
@@ -0,0 +1,24 @@
+import { useState, useEffect } from "react";
+
+/**
+ * Debounce a value — useful for search inputs that trigger API calls.
+ *
+ * @param value The raw value to debounce.
+ * @param delay Delay in milliseconds (default 300ms).
+ * @returns The debounced value that updates only after `delay` ms of inactivity.
+ *
+ * @example
+ * const [search, setSearch] = useState("");
+ * const debouncedSearch = useDebounce(search, 300);
+ * // use `debouncedSearch` in a TanStack Query key
+ */
+export function useDebounce(value: T, delay = 300): T {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delay);
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+
+ return debounced;
+}