feat(review-queue): MITRE update review queue for leads
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- New /techniques/review-queue page: lists all techniques flagged for review after a MITRE ATT&CK sync, grouped by tactic. Leads and admins can mark each one reviewed inline without leaving the page. - Sidebar: 'Review Queue' link (admin/red_lead/blue_lead only) with an amber badge showing the live pending count. - TechniqueDetailPage: amber banner when review_required=true explaining what happened and who can act; 'Mark as Reviewed' button now amber coloured for visual distinction. 'Leads only' chip shown for blue_tech. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ const TestCreatePage = React.lazy(() => import("./pages/TestCreatePage"));
|
|||||||
const TestDetailPage = React.lazy(() => import("./pages/TestDetailPage"));
|
const TestDetailPage = React.lazy(() => import("./pages/TestDetailPage"));
|
||||||
const TestCatalogPage = React.lazy(() => import("./pages/TestCatalogPage"));
|
const TestCatalogPage = React.lazy(() => import("./pages/TestCatalogPage"));
|
||||||
const ValidatedTestsPage = React.lazy(() => import("./pages/ValidatedTestsPage"));
|
const ValidatedTestsPage = React.lazy(() => import("./pages/ValidatedTestsPage"));
|
||||||
|
const ReviewQueuePage = React.lazy(() => import("./pages/ReviewQueuePage"));
|
||||||
const ReportsPage = React.lazy(() => import("./pages/ReportsPage"));
|
const ReportsPage = React.lazy(() => import("./pages/ReportsPage"));
|
||||||
const SystemPage = React.lazy(() => import("./pages/SystemPage"));
|
const SystemPage = React.lazy(() => import("./pages/SystemPage"));
|
||||||
const UsersPage = React.lazy(() => import("./pages/UsersPage"));
|
const UsersPage = React.lazy(() => import("./pages/UsersPage"));
|
||||||
@@ -52,6 +53,7 @@ export default function App() {
|
|||||||
<Route path="/techniques/:mitreId" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TechniqueDetailPage /></Suspense>} />
|
<Route path="/techniques/:mitreId" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><TechniqueDetailPage /></Suspense>} />
|
||||||
|
|
||||||
<Route path="/matrix" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><MatrixPage /></Suspense>} />
|
<Route path="/matrix" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><MatrixPage /></Suspense>} />
|
||||||
|
<Route path="/techniques/review-queue" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><ReviewQueuePage /></Suspense>} />
|
||||||
|
|
||||||
{/* ── Executive Dashboard (leads + admin) ──────────────── */}
|
{/* ── Executive Dashboard (leads + admin) ──────────────── */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
@@ -19,8 +20,10 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
GitCompareArrows,
|
GitCompareArrows,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
ClipboardCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { getTechniques } from "../api/techniques";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -35,6 +38,7 @@ const mainLinks: NavItem[] = [
|
|||||||
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ to: "/executive-dashboard", label: "Executive Dashboard", icon: Gauge, roles: ["admin", "red_lead", "blue_lead", "viewer"] },
|
{ to: "/executive-dashboard", label: "Executive Dashboard", icon: Gauge, roles: ["admin", "red_lead", "blue_lead", "viewer"] },
|
||||||
{ to: "/matrix", label: "ATT&CK Matrix", icon: Grid3X3 },
|
{ to: "/matrix", label: "ATT&CK Matrix", icon: Grid3X3 },
|
||||||
|
{ to: "/techniques/review-queue", label: "Review Queue", icon: ClipboardCheck, roles: ["admin", "red_lead", "blue_lead"] },
|
||||||
{
|
{
|
||||||
to: "/tests",
|
to: "/tests",
|
||||||
label: "Tests",
|
label: "Tests",
|
||||||
@@ -60,7 +64,7 @@ const systemLinks: NavItem[] = [
|
|||||||
{ to: "/audit", label: "Audit Log", icon: FileText },
|
{ to: "/audit", label: "Audit Log", icon: FileText },
|
||||||
];
|
];
|
||||||
|
|
||||||
function SidebarLink({ item }: { item: NavItem }) {
|
function SidebarLink({ item, badge }: { item: NavItem; badge?: number }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
if (item.children) {
|
if (item.children) {
|
||||||
@@ -104,6 +108,7 @@ function SidebarLink({ item }: { item: NavItem }) {
|
|||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.to}
|
to={item.to}
|
||||||
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
|
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
@@ -112,8 +117,13 @@ function SidebarLink({ item }: { item: NavItem }) {
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<item.icon className="h-5 w-5" />
|
<item.icon className="h-5 w-5 shrink-0" />
|
||||||
{item.label}
|
<span className="flex-1">{item.label}</span>
|
||||||
|
{badge !== undefined && badge > 0 && (
|
||||||
|
<span className="rounded-full bg-amber-500 px-1.5 py-0.5 text-[10px] font-bold text-white leading-none">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -123,10 +133,22 @@ export default function Sidebar() {
|
|||||||
const role = user?.role ?? "";
|
const role = user?.role ?? "";
|
||||||
const isAdmin = role === "admin";
|
const isAdmin = role === "admin";
|
||||||
|
|
||||||
|
const canSeeReviewQueue =
|
||||||
|
isAdmin || role === "red_lead" || role === "blue_lead";
|
||||||
|
|
||||||
|
// Fetch review queue count for the badge (only for roles that can see it)
|
||||||
|
const { data: reviewQueue } = useQuery({
|
||||||
|
queryKey: ["techniques", "review-queue"],
|
||||||
|
queryFn: () => getTechniques({ review_required: true }),
|
||||||
|
enabled: canSeeReviewQueue,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 min — don't hammer the API
|
||||||
|
});
|
||||||
|
const reviewCount = reviewQueue?.length ?? 0;
|
||||||
|
|
||||||
/** Returns true when the current user is allowed to see `item`. */
|
/** Returns true when the current user is allowed to see `item`. */
|
||||||
const canSee = (item: NavItem) => {
|
const canSee = (item: NavItem) => {
|
||||||
if (!item.roles) return true; // no restriction
|
if (!item.roles) return true;
|
||||||
if (isAdmin) return true; // admin sees everything
|
if (isAdmin) return true;
|
||||||
return item.roles.includes(role);
|
return item.roles.includes(role);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +165,11 @@ export default function Sidebar() {
|
|||||||
{/* Main nav */}
|
{/* Main nav */}
|
||||||
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
|
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
|
||||||
{mainLinks.filter(canSee).map((item) => (
|
{mainLinks.filter(canSee).map((item) => (
|
||||||
<SidebarLink key={item.to + item.label} item={item} />
|
<SidebarLink
|
||||||
|
key={item.to + item.label}
|
||||||
|
item={item}
|
||||||
|
badge={item.to === "/techniques/review-queue" ? reviewCount : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* System / Administration section — admin only */}
|
{/* System / Administration section — admin only */}
|
||||||
|
|||||||
227
frontend/src/pages/ReviewQueuePage.tsx
Normal file
227
frontend/src/pages/ReviewQueuePage.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
ClipboardCheck,
|
||||||
|
CheckCircle,
|
||||||
|
ExternalLink,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getTechniques, markTechniqueReviewed } from "../api/techniques";
|
||||||
|
import type { TechniqueSummary } from "../api/techniques";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
validated: "bg-green-500/10 text-green-400 border-green-500/20",
|
||||||
|
partial: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||||
|
in_progress: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||||
|
not_covered: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||||
|
not_evaluated: "bg-gray-500/10 text-gray-400 border-gray-600/20",
|
||||||
|
review_required:"bg-orange-500/10 text-orange-400 border-orange-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
validated: "Validated",
|
||||||
|
partial: "Partial",
|
||||||
|
in_progress: "In Progress",
|
||||||
|
not_covered: "Not Covered",
|
||||||
|
not_evaluated: "Not Evaluated",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null | undefined) {
|
||||||
|
if (!dateStr) return "—";
|
||||||
|
const utc = dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
|
||||||
|
return new Date(utc).toLocaleDateString("es-ES", {
|
||||||
|
year: "numeric", month: "short", day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReviewQueuePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const canReview =
|
||||||
|
user?.role === "admin" ||
|
||||||
|
user?.role === "red_lead" ||
|
||||||
|
user?.role === "blue_lead";
|
||||||
|
|
||||||
|
const { data: techniques, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ["techniques", "review-queue"],
|
||||||
|
queryFn: () => getTechniques({ review_required: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewMutation = useMutation({
|
||||||
|
mutationFn: (mitreId: string) => markTechniqueReviewed(mitreId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["techniques", "review-queue"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["techniques"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group by tactic for a cleaner layout
|
||||||
|
const byTactic = useMemo(() => {
|
||||||
|
if (!techniques) return [];
|
||||||
|
const map = new Map<string, TechniqueSummary[]>();
|
||||||
|
for (const t of techniques) {
|
||||||
|
const tactic = t.tactic || "Unknown";
|
||||||
|
if (!map.has(tactic)) map.set(tactic, []);
|
||||||
|
map.get(tactic)!.push(t);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
}, [techniques]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-amber-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||||
|
<AlertCircle className="h-10 w-10 text-red-400" />
|
||||||
|
<p className="text-red-400">Failed to load review queue</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = techniques?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ClipboardCheck className="h-7 w-7 text-amber-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Technique Review Queue</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-gray-400">
|
||||||
|
Techniques updated in MITRE ATT&CK that need to be reviewed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-sm font-medium text-amber-400">
|
||||||
|
{total} pending
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-400 hover:bg-gray-700 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What does this mean? */}
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
||||||
|
<p className="text-sm text-amber-300">
|
||||||
|
<span className="font-semibold">What does this mean?</span>{" "}
|
||||||
|
The MITRE ATT&CK sync detected that these techniques were updated in the official
|
||||||
|
ATT&CK dataset. A lead or admin should review each one to confirm the change has
|
||||||
|
been acknowledged before marking it as reviewed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{total === 0 && (
|
||||||
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-16 text-center">
|
||||||
|
<CheckCircle className="mx-auto mb-3 h-12 w-12 text-green-500/50" />
|
||||||
|
<p className="text-lg font-medium text-white">All caught up!</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
No techniques are currently flagged for review.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table grouped by tactic */}
|
||||||
|
{byTactic.map(([tactic, items]) => (
|
||||||
|
<div key={tactic} className="rounded-xl border border-gray-800 bg-gray-900">
|
||||||
|
{/* Tactic header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold capitalize text-gray-300">{tactic}</h2>
|
||||||
|
<span className="text-xs text-gray-500">{items.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
|
||||||
|
<th className="px-5 py-3 text-left">MITRE ID</th>
|
||||||
|
<th className="px-5 py-3 text-left">Name</th>
|
||||||
|
<th className="px-5 py-3 text-left">Coverage</th>
|
||||||
|
<th className="px-5 py-3 text-left">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800/50">
|
||||||
|
{items.map((tech) => (
|
||||||
|
<tr
|
||||||
|
key={tech.mitre_id}
|
||||||
|
className="hover:bg-gray-800/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span className="font-mono text-xs font-semibold text-cyan-400">
|
||||||
|
{tech.mitre_id}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-gray-200 max-w-xs">
|
||||||
|
<span className="line-clamp-1">{tech.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||||
|
STATUS_COLORS[tech.status_global] ?? STATUS_COLORS.not_evaluated
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[tech.status_global] ?? tech.status_global}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* View detail */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
|
||||||
|
className="flex items-center gap-1 rounded-lg border border-gray-700 px-2.5 py-1.5 text-xs text-gray-400 hover:border-gray-600 hover:text-white transition-colors"
|
||||||
|
title="Open technique"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Mark as reviewed — leads/admin only */}
|
||||||
|
{canReview && (
|
||||||
|
<button
|
||||||
|
onClick={() => reviewMutation.mutate(tech.mitre_id)}
|
||||||
|
disabled={reviewMutation.isPending && reviewMutation.variables === tech.mitre_id}
|
||||||
|
className="flex items-center gap-1 rounded-lg bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-amber-500 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{reviewMutation.isPending && reviewMutation.variables === tech.mitre_id ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Mark Reviewed
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -163,7 +163,7 @@ export default function TechniqueDetailPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => reviewMutation.mutate()}
|
onClick={() => reviewMutation.mutate()}
|
||||||
disabled={reviewMutation.isPending}
|
disabled={reviewMutation.isPending}
|
||||||
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
|
className="flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 disabled:opacity-50 transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
{reviewMutation.isPending ? (
|
{reviewMutation.isPending ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -176,6 +176,31 @@ export default function TechniqueDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Review required banner */}
|
||||||
|
{technique.review_required && (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4">
|
||||||
|
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-amber-300">
|
||||||
|
This technique has been updated in MITRE ATT&CK
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-amber-400/70">
|
||||||
|
The MITRE ATT&CK sync detected changes to this technique.
|
||||||
|
{technique.mitre_last_modified && (
|
||||||
|
<> Last modified in ATT&CK: <span className="font-mono">{technique.mitre_last_modified.slice(0, 10)}</span>.</>
|
||||||
|
)}
|
||||||
|
{" "}A lead or admin should review the changes and click{" "}
|
||||||
|
<span className="font-semibold">Mark as Reviewed</span> to acknowledge them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!canReview && (
|
||||||
|
<span className="shrink-0 rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-400">
|
||||||
|
Leads only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Info Section */}
|
{/* Info Section */}
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|||||||
Reference in New Issue
Block a user