Initial commit - frontend
This commit is contained in:
32
src/app/(protected)/admin/devices/page.tsx
Normal file
32
src/app/(protected)/admin/devices/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// src/app/admin/devices/page.tsx
|
||||
import { cookies } from 'next/headers';
|
||||
import { requireAuthOrRedirect } from '@/lib/authServer';
|
||||
import axiosServer from '@/lib/axiosServer';
|
||||
import DeviceTableSection from '@/components/admin/DeviceTableSection';
|
||||
|
||||
interface DeviceDTO {
|
||||
deviceId: number;
|
||||
hostname: string;
|
||||
lastCheckedIn: string;
|
||||
clientId: number;
|
||||
clientName: string;
|
||||
clientIdentifier: string;
|
||||
}
|
||||
|
||||
|
||||
export default async function DeviceManagementPage() {
|
||||
const user = await requireAuthOrRedirect();
|
||||
const cookieStore = cookies();
|
||||
// @ts-expect-error cookies() isn't really async
|
||||
const token = cookieStore.get('authToken')?.value;
|
||||
|
||||
const res = await axiosServer.get('/admin/devices', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${user.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const devices: DeviceDTO[] = res.data;
|
||||
|
||||
return <DeviceTableSection initialDevices={devices} />;
|
||||
}
|
||||
14
src/app/(protected)/admin/settings/page.tsx
Normal file
14
src/app/(protected)/admin/settings/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
// src/app/admin/settings/page.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import AdminControlsPanel from '@/components/admin/AdminControlsPanel';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<AdminControlsPanel />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
142
src/app/(protected)/admin/statistics/page.tsx
Normal file
142
src/app/(protected)/admin/statistics/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, Typography, Grid, CircularProgress, Box } from '@mui/material';
|
||||
import api from '@/lib/axios';
|
||||
|
||||
interface Stats {
|
||||
total: number;
|
||||
missing_title: number;
|
||||
missing_severity: number;
|
||||
missing_cvss_score: number;
|
||||
missing_cvss_vector: number;
|
||||
missing_references: number;
|
||||
missing_published_date: number;
|
||||
missing_description: number;
|
||||
missing_cwe: number;
|
||||
missing_cisa_kev: number;
|
||||
missing_cert_notes: number;
|
||||
missing_cert_alerts: number;
|
||||
}
|
||||
|
||||
export default function CVEStatisticsPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/statistics');
|
||||
setStats(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch CVE stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" alignItems="center" justifyContent="center" mt={4}>
|
||||
<CircularProgress sx={{ mb: 2 }} />
|
||||
<Typography variant="body1">Fetching CVE statistics...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return <Typography variant="h6">No data available</Typography>;
|
||||
}
|
||||
|
||||
const {
|
||||
total,
|
||||
missing_title,
|
||||
missing_severity,
|
||||
missing_cvss_score,
|
||||
missing_cvss_vector,
|
||||
missing_references,
|
||||
missing_published_date,
|
||||
missing_description,
|
||||
missing_cwe,
|
||||
missing_cisa_kev,
|
||||
missing_cert_notes,
|
||||
missing_cert_alerts,
|
||||
} = stats;
|
||||
|
||||
const complete = (value: number) => (total > 0 ? (((total - value) / total) * 100).toFixed(2) : '0');
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Typography variant="h4" gutterBottom>
|
||||
CVE Enrichment Statistics
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<StatCard label="Total CVEs" value={total} />
|
||||
<StatCard label="Missing Title" value={missing_title} />
|
||||
<StatCard label="Missing Severity" value={missing_severity} />
|
||||
<StatCard label="Missing CVSS Score" value={missing_cvss_score} />
|
||||
<StatCard label="Missing CVSS Vector" value={missing_cvss_vector} />
|
||||
<StatCard label="Missing References" value={missing_references} />
|
||||
<StatCard label="Missing Published Date" value={missing_published_date} />
|
||||
<StatCard label="Missing Description" value={missing_description} />
|
||||
<StatCard label="Missing CWE" value={missing_cwe} />
|
||||
<StatCard label="Missing CISA KEV" value={missing_cisa_kev} />
|
||||
<StatCard label="Missing CERT Notes" value={missing_cert_notes} />
|
||||
<StatCard label="Missing CERT Alerts" value={missing_cert_alerts} />
|
||||
</Grid>
|
||||
|
||||
<Typography variant="h5" gutterBottom sx={{ mt: 5 }}>
|
||||
Completion Rates
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<StatCard label="Title Completion" value={Number(complete(missing_title))} isPercentage />
|
||||
<StatCard label="Severity Completion" value={Number(complete(missing_severity))} isPercentage />
|
||||
<StatCard label="CVSS Score Completion" value={Number(complete(missing_cvss_score))} isPercentage />
|
||||
<StatCard label="CVSS Vector Completion" value={Number(complete(missing_cvss_vector))} isPercentage />
|
||||
<StatCard label="References Completion" value={Number(complete(missing_references))} isPercentage />
|
||||
<StatCard label="Published Date Completion" value={Number(complete(missing_published_date))} isPercentage />
|
||||
<StatCard label="Description Completion" value={Number(complete(missing_description))} isPercentage />
|
||||
<StatCard label="CWE Completion" value={Number(complete(missing_cwe))} isPercentage />
|
||||
<StatCard label="CISA KEV Completion" value={Number(complete(missing_cisa_kev))} isPercentage />
|
||||
<StatCard label="CERT Notes Completion" value={Number(complete(missing_cert_notes))} isPercentage />
|
||||
<StatCard label="CERT Alerts Completion" value={Number(complete(missing_cert_alerts))} isPercentage />
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
isPercentage?: boolean;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, isPercentage = false }: StatCardProps) {
|
||||
let color: 'error' | 'warning' | 'success' | 'textPrimary' = 'textPrimary';
|
||||
|
||||
if (isPercentage) {
|
||||
if (value >= 90) color = 'success';
|
||||
else if (value >= 70) color = 'warning';
|
||||
else color = 'error';
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
<Card sx={{ borderRadius: 2, boxShadow: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="h4" color={color}>
|
||||
{isPercentage ? `${value.toFixed(2)}%` : value.toLocaleString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
36
src/app/(protected)/admin/users/page.tsx
Normal file
36
src/app/(protected)/admin/users/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
// src/app/admin/users/page.tsx
|
||||
import { cookies } from 'next/headers';
|
||||
import { requireAuthOrRedirect } from '@/lib/authServer';
|
||||
import axiosServer from '@/lib/axiosServer';
|
||||
import UserTableSection from '@/components/admin/UserTableSection';
|
||||
|
||||
|
||||
interface UserDTO {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
clientName: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export default async function UserManagementPage() {
|
||||
const user = await requireAuthOrRedirect();
|
||||
const cookieStore = cookies();
|
||||
// @ts-expect-error - cookies() is not actually async, type is misleading
|
||||
const token = cookieStore.get('authToken')?.value;
|
||||
|
||||
|
||||
const res = await axiosServer.get('/admin/users', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${user.token}`, // token is guaranteed to exist
|
||||
},
|
||||
});
|
||||
|
||||
const users: UserDTO[] = res.data;
|
||||
|
||||
return <UserTableSection initialUsers={users} />;
|
||||
}
|
||||
140
src/app/(protected)/dashboard/page.tsx
Normal file
140
src/app/(protected)/dashboard/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Typography, Link as MuiLink, Grid, Collapse, Paper } from '@mui/material';
|
||||
import Count_Critical from '@/components/gauges/Count_Critical';
|
||||
import Count_High from '@/components/gauges/Count_High';
|
||||
import Count_Medium from '@/components/gauges/Count_Medium';
|
||||
import Count_Low from '@/components/gauges/Count_Low';
|
||||
import { useState } from 'react';
|
||||
import VulnerabilityTableWithControls from '@/components/vuln/VulnerabilityTableWithControls';
|
||||
import { useDeviceContext } from '@/context/DeviceContext';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { deviceVulns, devices } = useDeviceContext();
|
||||
|
||||
const totalDevices = devices.length;
|
||||
const allVulns = Object.values(deviceVulns).flat();
|
||||
|
||||
// 1️⃣ Total vulnerability counts (for card numbers)
|
||||
const criticalVulnCount = allVulns.filter(v => {
|
||||
const sev = v.severity.toLowerCase();
|
||||
return sev === 'critical' || sev === 'unknown';
|
||||
}).length;
|
||||
|
||||
const highVulnCount = allVulns.filter(v => v.severity.toLowerCase() === 'high').length;
|
||||
const mediumVulnCount = allVulns.filter(v => v.severity.toLowerCase() === 'medium').length;
|
||||
const lowVulnCount = allVulns.filter(v => v.severity.toLowerCase() === 'low').length;
|
||||
|
||||
// 2️⃣ Total affected devices (for subtext)
|
||||
const criticalDeviceCount = Object.values(deviceVulns).filter(vulns =>
|
||||
vulns.some(v => {
|
||||
const sev = v.severity.toLowerCase();
|
||||
return sev === 'critical' || sev === 'unknown';
|
||||
})
|
||||
).length;
|
||||
|
||||
const highDeviceCount = Object.values(deviceVulns).filter(vulns =>
|
||||
vulns.some(v => v.severity.toLowerCase() === 'high')
|
||||
).length;
|
||||
const mediumDeviceCount = Object.values(deviceVulns).filter(vulns =>
|
||||
vulns.some(v => v.severity.toLowerCase() === 'medium')
|
||||
).length;
|
||||
const lowDeviceCount = Object.values(deviceVulns).filter(vulns =>
|
||||
vulns.some(v => v.severity.toLowerCase() === 'low')
|
||||
).length;
|
||||
|
||||
|
||||
|
||||
const [openSeverity, setOpenSeverity] = useState<'critical' | 'high' | 'medium' | 'low' | null>(null);
|
||||
|
||||
const filteredVulns = openSeverity
|
||||
? allVulns.filter(v => {
|
||||
const sev = v.severity.toLowerCase();
|
||||
if (openSeverity === 'critical') {
|
||||
return sev === 'critical' || sev === 'unknown';
|
||||
}
|
||||
return sev === openSeverity;
|
||||
})
|
||||
: [];
|
||||
|
||||
|
||||
const handleCardClick = (severity: 'critical' | 'high' | 'medium' | 'low') => {
|
||||
setOpenSeverity(prev => (prev === severity ? null : severity));
|
||||
};
|
||||
|
||||
const severityLabels: Record<string, string> = {
|
||||
critical: 'Critical Vulnerabilities',
|
||||
high: 'High Vulnerabilities',
|
||||
medium: 'Medium Vulnerabilities',
|
||||
low: 'Low Vulnerabilities',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
<Typography variant="h4" gutterBottom>Vulnerability Dashboard</Typography>
|
||||
|
||||
<Typography variant="body1" color="textSecondary" gutterBottom>
|
||||
This dashboard provides an overview of vulnerabilities across all devices, categorized by severity.<br />
|
||||
Vulnerabilities marked as{' '}
|
||||
<span style={{ color: '#d32f2f', fontWeight: 600 }}>UNKNOWN</span> severity are included under{' '}
|
||||
<span style={{ color: '#d32f2f', fontWeight: 600 }}>CRITICAL</span> for safety.<br />
|
||||
Click on a severity card below to get started.<br />
|
||||
For detailed per-device views, please use the{' '}
|
||||
<MuiLink href="/devices" underline="hover" fontWeight="bold">
|
||||
Devices
|
||||
</MuiLink>{' '}
|
||||
section.
|
||||
</Typography>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* 🔻 Grid for Statistics */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 3 }} onClick={() => handleCardClick('critical')} sx={{ cursor: 'pointer' }}>
|
||||
<Count_Critical
|
||||
criticalCount={criticalVulnCount}
|
||||
totalDevices={totalDevices}
|
||||
affectedDevices={criticalDeviceCount}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 3 }} onClick={() => handleCardClick('high')} sx={{ cursor: 'pointer' }}>
|
||||
<Count_High
|
||||
highCount={highVulnCount}
|
||||
totalDevices={totalDevices}
|
||||
affectedDevices={highDeviceCount}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 3 }} onClick={() => handleCardClick('medium')} sx={{ cursor: 'pointer' }}>
|
||||
<Count_Medium
|
||||
mediumCount={mediumVulnCount}
|
||||
totalDevices={totalDevices}
|
||||
affectedDevices={mediumDeviceCount}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 3 }} onClick={() => handleCardClick('low')} sx={{ cursor: 'pointer' }}>
|
||||
<Count_Low
|
||||
lowCount={lowVulnCount}
|
||||
totalDevices={totalDevices}
|
||||
affectedDevices={lowDeviceCount}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Drawer Table */}
|
||||
<Collapse in={!!openSeverity} timeout="auto" unmountOnExit>
|
||||
<Box mt={4}>
|
||||
<Paper elevation={3} sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{openSeverity && severityLabels[openSeverity]}
|
||||
</Typography>
|
||||
<VulnerabilityTableWithControls vulns={filteredVulns} />
|
||||
</Paper>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/(protected)/devices/layout.tsx
Normal file
28
src/app/(protected)/devices/layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// app/devices/layout.tsx
|
||||
import { cookies } from 'next/headers';
|
||||
import { DeviceProvider } from '@/context/DeviceContext';
|
||||
import '../../globals.css';
|
||||
import { Box } from '@mui/material'; // 👈 MUI Box import
|
||||
|
||||
export default async function DevicesLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
|
||||
return (
|
||||
<DeviceProvider>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
p: 0,
|
||||
m: 0,
|
||||
'& > main': {
|
||||
p: 0, // remove padding from inner Box/main
|
||||
m: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</DeviceProvider>
|
||||
);
|
||||
|
||||
}
|
||||
10
src/app/(protected)/devices/page.tsx
Normal file
10
src/app/(protected)/devices/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/app/devices/page.tsx
|
||||
|
||||
|
||||
import DevicesClient from '@/components/client/DevicesClient';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function DevicesPage() {
|
||||
return <DevicesClient />;
|
||||
}
|
||||
43
src/app/(protected)/layout.tsx
Normal file
43
src/app/(protected)/layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// src/app/(protected)/layout.tsx
|
||||
import { cookies } from 'next/headers';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import Providers from '@/components/Providers';
|
||||
import { disableConsoleInProd } from '@/lib/disableConsole';
|
||||
disableConsoleInProd();
|
||||
|
||||
|
||||
// 🛡️ Console log disabling block
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'development') {
|
||||
console.log = console.debug = console.info = () => {};
|
||||
}
|
||||
|
||||
export default async function ProtectedLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies(); // ✅ valid now that we're inside an async function
|
||||
const token = cookieStore.get('authToken')?.value;
|
||||
|
||||
let username = '';
|
||||
let displayname = '';
|
||||
let roles: string[] = [];
|
||||
|
||||
if (token && token.split('.').length === 3) {
|
||||
try {
|
||||
const decoded = jwtDecode<{
|
||||
sub: string;
|
||||
displayname: string;
|
||||
roles?: string[];
|
||||
}>(token);
|
||||
|
||||
username = decoded.sub ?? '';
|
||||
displayname = decoded.displayname ?? '';
|
||||
roles = decoded.roles ?? [];
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Failed to decode token:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Providers username={username} displayname={displayname} roles={roles}>
|
||||
{children}
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
380
src/app/(protected)/software/page.tsx
Normal file
380
src/app/(protected)/software/page.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, Collapse, IconButton, Typography, Box, CircularProgress, TextField,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,Tooltip
|
||||
} from '@mui/material';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { useDeviceContext } from '@/context/DeviceContext';
|
||||
import api from '@/lib/axios';
|
||||
|
||||
interface CachedSoftwareEntry {
|
||||
id: number;
|
||||
deviceId: number;
|
||||
hostname: string;
|
||||
softwareName: string;
|
||||
appVersion: string;
|
||||
publisher: string;
|
||||
lastUpdated: string;
|
||||
totalCves: number;
|
||||
cveList: string;
|
||||
}
|
||||
|
||||
interface DeviceVulnerability {
|
||||
cveId: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
score?: number;
|
||||
publishedDate: string;
|
||||
lastModifiedDate: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
interface GroupedSoftwareEntry {
|
||||
softwareName: string;
|
||||
totalDevices: number;
|
||||
instances: CachedSoftwareEntry[];
|
||||
}
|
||||
|
||||
export default function SoftwarePage() {
|
||||
const [softwareList, setSoftwareList] = useState<GroupedSoftwareEntry[]>([]);
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [drawerPageStates, setDrawerPageStates] = useState<Record<number, { page: number, pageSize: number }>>({});
|
||||
const [page, setPage] = useState(0); // Top-level page
|
||||
const [pageSize, setPageSize] = useState(10); // Top-level pageSize (default 10)
|
||||
const [openCveDialog, setOpenCveDialog] = useState(false);
|
||||
const [selectedCves, setSelectedCves] = useState<DeviceVulnerability[]>([]);
|
||||
const [selectedHostname, setSelectedHostname] = useState<string>('');
|
||||
const { detailedCveLookup } = useDeviceContext();
|
||||
const [sortMode, setSortMode] = useState('vulnerabilities_desc'); // Default if you want "vulnerable first"
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSoftware = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.get<CachedSoftwareEntry[]>('/cached/software/summary');
|
||||
|
||||
const grouped: { [key: string]: CachedSoftwareEntry[] } = {};
|
||||
|
||||
res.data.forEach(entry => {
|
||||
if (!grouped[entry.softwareName]) {
|
||||
grouped[entry.softwareName] = [];
|
||||
}
|
||||
grouped[entry.softwareName].push(entry);
|
||||
});
|
||||
|
||||
let formatted = Object.entries(grouped).map(([softwareName, instances]) => ({
|
||||
softwareName,
|
||||
totalDevices: instances.length,
|
||||
instances,
|
||||
}));
|
||||
|
||||
// 🔥 Sort: put groups with the most vulnerable instances at the top
|
||||
formatted = formatted.sort((a, b) => {
|
||||
const aVulnerable = a.instances.some(inst => inst.totalCves > 0) ? 1 : 0;
|
||||
const bVulnerable = b.instances.some(inst => inst.totalCves > 0) ? 1 : 0;
|
||||
return bVulnerable - aVulnerable; // Vulnerable groups first
|
||||
});
|
||||
|
||||
setSoftwareList(formatted);
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to fetch cached software summary:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSoftware();
|
||||
}, []);
|
||||
|
||||
|
||||
const toggleRow = (index: number) => {
|
||||
setExpandedIndex(expandedIndex === index ? null : index);
|
||||
|
||||
// 🔥 Reset page state for that drawer
|
||||
setDrawerPageStates(prev => ({
|
||||
...prev,
|
||||
[index]: { page: 0, pageSize: 10 }
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<Typography variant="h4" gutterBottom>Installed Software (Cached)</Typography>
|
||||
|
||||
<Box display="flex" flexWrap="wrap" alignItems="center" justifyContent="space-between" gap={2} mt={2}>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Browse cached installed software across your environment. Sort and explore by device count, name, or vulnerability presence.
|
||||
</Typography>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<TextField
|
||||
select
|
||||
label="Sort by"
|
||||
value={sortMode}
|
||||
onChange={(e) => {
|
||||
setSortMode(e.target.value);
|
||||
setPage(0);
|
||||
setExpandedIndex(null);
|
||||
}}
|
||||
SelectProps={{ native: true }}
|
||||
size="small"
|
||||
sx={{ width: 220 }}
|
||||
>
|
||||
<option value="alpha_asc">Alphabetical (A → Z)</option>
|
||||
<option value="alpha_desc">Alphabetical (Z → A)</option>
|
||||
<option value="devices_asc">Total Devices (Ascending)</option>
|
||||
<option value="devices_desc">Total Devices (Descending)</option>
|
||||
<option value="vulnerabilities_asc">Vulnerabilities (Ascending)</option>
|
||||
<option value="vulnerabilities_desc">Vulnerabilities (Descending)</option>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label="Rows per page"
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
SelectProps={{ native: true }}
|
||||
size="small"
|
||||
sx={{ width: 120 }}
|
||||
>
|
||||
{[10, 25, 50, 100].map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<IconButton onClick={() => setPage((prev) => Math.max(prev - 1, 0))} disabled={page === 0}>
|
||||
◀
|
||||
</IconButton>
|
||||
<IconButton onClick={() => setPage((prev) => (prev + 1) * pageSize < softwareList.length ? prev + 1 : prev)} disabled={(page + 1) * pageSize >= softwareList.length}>
|
||||
▶
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center"><CircularProgress /></Box>
|
||||
) : (
|
||||
<TableContainer component={Paper} elevation={1}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell><strong>Software Name</strong></TableCell>
|
||||
<TableCell align="center"><strong>Total Devices</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{[...softwareList]
|
||||
.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'alpha_asc':
|
||||
return a.softwareName.localeCompare(b.softwareName);
|
||||
case 'alpha_desc':
|
||||
return b.softwareName.localeCompare(a.softwareName);
|
||||
case 'devices_asc':
|
||||
return a.totalDevices - b.totalDevices;
|
||||
case 'devices_desc':
|
||||
return b.totalDevices - a.totalDevices;
|
||||
case 'vulnerabilities_asc':
|
||||
const aVulns = a.instances.reduce((sum, inst) => sum + inst.totalCves, 0);
|
||||
const bVulns = b.instances.reduce((sum, inst) => sum + inst.totalCves, 0);
|
||||
return aVulns - bVulns;
|
||||
case 'vulnerabilities_desc':
|
||||
const aVulnsDesc = a.instances.reduce((sum, inst) => sum + inst.totalCves, 0);
|
||||
const bVulnsDesc = b.instances.reduce((sum, inst) => sum + inst.totalCves, 0);
|
||||
return bVulnsDesc - aVulnsDesc;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
.slice(page * pageSize, (page + 1) * pageSize) // 🥇 paginate AFTER sorting
|
||||
.map((row, index) => {
|
||||
const globalIndex = index + page * pageSize; // ✅
|
||||
|
||||
return (
|
||||
<React.Fragment key={row.softwareName}>
|
||||
<TableRow
|
||||
hover
|
||||
onClick={() => toggleRow(globalIndex)} // ✅
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
<TableCell
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleRow(globalIndex);
|
||||
}}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleRow(globalIndex);
|
||||
}}
|
||||
>
|
||||
{expandedIndex === globalIndex ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell>{row.softwareName}</TableCell>
|
||||
<TableCell align="center">{row.totalDevices}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} sx={{ p: 0 }}>
|
||||
<Collapse in={expandedIndex === globalIndex} timeout="auto" unmountOnExit>
|
||||
<Box margin={2}>
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell><strong>Hostname</strong></TableCell>
|
||||
<TableCell><strong>Version</strong></TableCell>
|
||||
<TableCell><strong>Publisher</strong></TableCell>
|
||||
<TableCell align="center"><strong>CVEs</strong></TableCell>
|
||||
<TableCell><strong>Last Updated</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{row.instances.map((instance) => (
|
||||
<TableRow
|
||||
key={instance.id}
|
||||
hover
|
||||
onClick={() => {
|
||||
const cveIds = instance.cveList.split(',');
|
||||
const uniqueCveIds = Array.from(new Set(cveIds));
|
||||
const fullDetails = uniqueCveIds
|
||||
.map(cveId => {
|
||||
const cve = detailedCveLookup[cveId];
|
||||
if (cve) {
|
||||
return {
|
||||
cveId: cve.cveId,
|
||||
title: cve.title,
|
||||
severity: cve.severity,
|
||||
score: cve.score,
|
||||
publishedDate: cve.publishedDate ?? '',
|
||||
lastModifiedDate: cve.lastModifiedDate ?? cve.publishedDate ?? '',
|
||||
} as DeviceVulnerability;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as DeviceVulnerability[];
|
||||
|
||||
setSelectedCves(fullDetails);
|
||||
setSelectedHostname(instance.hostname);
|
||||
setOpenCveDialog(true);
|
||||
}}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
<TableCell>{instance.hostname}</TableCell>
|
||||
<TableCell>{instance.appVersion}</TableCell>
|
||||
<TableCell>{instance.publisher || 'Unknown Publisher'}</TableCell>
|
||||
<TableCell align="center">
|
||||
{instance.cveList
|
||||
? (
|
||||
<Typography variant="body2" fontWeight={600} color="primary.main">
|
||||
{Array.from(new Set(instance.cveList.split(','))).length}
|
||||
</Typography>
|
||||
)
|
||||
: (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
None
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(instance.lastUpdated).toLocaleDateString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
<Dialog sx={{ p: 0 }} open={openCveDialog} onClose={() => setOpenCveDialog(false)} fullWidth maxWidth="md">
|
||||
<DialogTitle>CVEs for {selectedHostname}</DialogTitle>
|
||||
<DialogContent sx={{ p: 0 }} dividers>
|
||||
{selectedCves.length === 0 ? (
|
||||
<Typography>No CVEs found.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ minWidth: 150, borderRight: '1px solid rgba(81,81,81,1)' }}><strong>CVE ID</strong></TableCell>
|
||||
<TableCell sx={{ minWidth: 100, borderRight: '1px solid rgba(81,81,81,1)' }}><strong>Severity</strong></TableCell>
|
||||
<TableCell sx={{ minWidth: 80, borderRight: '1px solid rgba(81,81,81,1)' }}><strong>Score</strong></TableCell>
|
||||
<TableCell sx={{ minWidth: 300, borderRight: '1px solid rgba(81,81,81,1)' }}><strong>Description</strong></TableCell>
|
||||
<TableCell sx={{ minWidth: 130 }}><strong>Last Updated</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{selectedCves.map((cve, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell sx={{ minWidth: 150, borderRight: '1px solid rgba(81,81,81,1)' }}>
|
||||
<a
|
||||
href={`https://nvd.nist.gov/vuln/detail/${cve.cveId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1976d2', textDecoration: 'underline' }}
|
||||
>
|
||||
{cve.cveId}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell sx={{ minWidth: 100, borderRight: '1px solid rgba(81,81,81,1)' }}>
|
||||
{cve.severity
|
||||
? cve.severity.charAt(0).toUpperCase() + cve.severity.slice(1).toLowerCase()
|
||||
: 'Unknown'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ minWidth: 80, borderRight: '1px solid rgba(81,81,81,1)' }}>
|
||||
{cve.score === -1 || cve.score === undefined ? 'Unknown' : cve.score}
|
||||
</TableCell>
|
||||
<TableCell sx={{ minWidth: 300, borderRight: '1px solid rgba(81,81,81,1)' }}>
|
||||
{cve.title || 'Unknown'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ minWidth: 130 }}>
|
||||
<Tooltip
|
||||
title={`Published: ${new Date(cve.publishedDate).toLocaleDateString()}`}
|
||||
arrow
|
||||
placement="top"
|
||||
disableInteractive
|
||||
>
|
||||
<span style={{ cursor: 'help', textDecoration: 'underline dotted' }}>
|
||||
{new Date(cve.lastModifiedDate).toLocaleDateString()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
</Table>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpenCveDialog(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/app/(protected)/user-profile/page.tsx
Normal file
174
src/app/(protected)/user-profile/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Container,
|
||||
TextField,
|
||||
Button,
|
||||
Stack,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import api from "@/lib/axios";
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import ChangePasswordDrawer from '@/components/ChangePasswordDrawer'; // Adjust path as needed
|
||||
|
||||
|
||||
interface UserProfile {
|
||||
username: string;
|
||||
displayName: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const router = useRouter();
|
||||
const { refreshAuth, username: loggedInUsername } = useAuth();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/user/profile').then(({ data }) => {
|
||||
setProfile(data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (profile) {
|
||||
setProfile({ ...profile, [e.target.name]: e.target.value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!profile) return;
|
||||
|
||||
setSaving(true);
|
||||
|
||||
const { username, displayName, firstName, lastName, email } = profile;
|
||||
|
||||
api.post('/user/profile', { username, displayName, firstName, lastName, email })
|
||||
.then((res) => {
|
||||
const newToken = res.data.token;
|
||||
if (newToken) {
|
||||
document.cookie = `authToken=${newToken}; path=/; secure; max-age=3600; samesite=strict`;
|
||||
}
|
||||
|
||||
|
||||
alert('Profile updated successfully, please login again!');
|
||||
document.cookie = 'authToken=; Max-Age=0; path=/;';
|
||||
router.push('/login');
|
||||
|
||||
})
|
||||
.catch(() => {
|
||||
setSaving(false);
|
||||
alert('Error updating profile');
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (loading || !profile) {
|
||||
return (
|
||||
<Container>
|
||||
<Box sx={{ mt: 4, textAlign: 'center' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>User Profile</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Username"
|
||||
name="username"
|
||||
value={profile.username}
|
||||
disabled
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
<TextField
|
||||
label="Display Name"
|
||||
name="displayName"
|
||||
value={profile.displayName}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<TextField
|
||||
label="First Name"
|
||||
name="firstName"
|
||||
value={profile.firstName}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
<TextField
|
||||
label="Last Name"
|
||||
name="lastName"
|
||||
value={profile.lastName}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
<TextField
|
||||
label="Email"
|
||||
name="email"
|
||||
value={profile.email}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
type="email"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || loggedInUsername === 'testuser'} // 👈 disable for specific user
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
|
||||
{loggedInUsername === 'testuser' && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
p: 1.5,
|
||||
borderRadius: 1,
|
||||
borderLeft: '4px solid #0288d1',
|
||||
fontSize: '0.875rem',
|
||||
color: '#333',
|
||||
}}
|
||||
>
|
||||
Profile updates are disabled for this user.
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
|
||||
<ChangePasswordDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
218
src/app/(protected)/vulnerabilities/page.tsx
Normal file
218
src/app/(protected)/vulnerabilities/page.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
TablePagination,
|
||||
TextField,
|
||||
TableSortLabel,
|
||||
} from '@mui/material';
|
||||
import { brandColors } from '@/app/styles/colors';
|
||||
import { useThemeMode } from '@/context/ThemeContext';
|
||||
import { Box } from '@mui/material';
|
||||
import api from '@/lib/axios';
|
||||
|
||||
|
||||
export default function VulnerabilityAdminPage() {
|
||||
type Cve = {
|
||||
id: string;
|
||||
description: string;
|
||||
publishedDate: string;
|
||||
lastModifiedDate: string;
|
||||
severity: string;
|
||||
cvssScore: number;
|
||||
};
|
||||
|
||||
const [data, setData] = useState<Cve[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [orderBy, setOrderBy] = useState<keyof Cve>('lastModifiedDate');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const { darkMode } = useThemeMode();
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(search);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearch(search);
|
||||
setPage(0); // Reset page when new search starts
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(handler);
|
||||
}, [search]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const queryObj: Record<string, string> = {
|
||||
page: String(page),
|
||||
size: String(pageSize),
|
||||
sort: `${orderBy},${order}`,
|
||||
};
|
||||
|
||||
if (debouncedSearch.trim() !== '') {
|
||||
queryObj.search = debouncedSearch.trim();
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(queryObj);
|
||||
const res = await api.get('/public/vulnerabilities', { params: queryObj });
|
||||
setData(res.data.content || []);
|
||||
setTotalPages(res.data.totalPages || 0);
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Error fetching CVE data:', err);
|
||||
setData([]);
|
||||
setTotalPages(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [page, debouncedSearch, pageSize, orderBy, order]);
|
||||
|
||||
|
||||
const handleSort = (column: keyof Cve) => {
|
||||
const isAsc = orderBy === column && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(column);
|
||||
setPage(0);
|
||||
};
|
||||
const cellStyle = {
|
||||
border: '1px solid rgba(224, 224, 224, 1)',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: '140px',
|
||||
textAlign: 'center'
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold">CVE Admin</h1>
|
||||
<Box sx={{ display:'flex',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search CVE ID, severity, or description..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setDebouncedSearch(search);
|
||||
setPage(0);
|
||||
}
|
||||
}}
|
||||
sx={{ maxWidth: 400 }}
|
||||
/>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalPages * pageSize}
|
||||
page={page}
|
||||
onPageChange={(_, newPage) => setPage(newPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setPageSize(parseInt(e.target.value, 10));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
|
||||
|
||||
</Box>
|
||||
<Box sx={{
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: darkMode ? brandColors.paperDark : brandColors.paperLight, // 💡 match theme
|
||||
}}>
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
elevation={0}
|
||||
sx={{
|
||||
backgroundColor: 'inherit', // 👈 match parent
|
||||
borderRadius: 0, // let Box handle it
|
||||
}}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{['id', 'description', 'severity', 'cvssScore', 'lastModifiedDate'].map((key) => (
|
||||
<TableCell
|
||||
key={key}
|
||||
sortDirection={orderBy === key ? order : false}
|
||||
sx={cellStyle}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={orderBy === key}
|
||||
direction={orderBy === key ? order : 'asc'}
|
||||
onClick={() => handleSort(key as keyof Cve)}
|
||||
>
|
||||
{{
|
||||
id: 'CVE ID',
|
||||
description: 'Description',
|
||||
severity: 'Severity',
|
||||
cvssScore: 'CVSS',
|
||||
lastModifiedDate: 'Last Modified',
|
||||
}[key]}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="center" sx={cellStyle}>
|
||||
<CircularProgress size={24} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell sx={cellStyle}>
|
||||
<a
|
||||
href={`https://nvd.nist.gov/vuln/detail/${row.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: darkMode ? brandColors.linkDark : brandColors.linkLight,
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{row.id}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell sx={{ ...cellStyle, whiteSpace: 'normal', textAlign: 'left'}}>{row.description}</TableCell>
|
||||
<TableCell sx={cellStyle}>
|
||||
{row.severity === 'UNKNOWN' ? 'N/A' : row.severity}
|
||||
</TableCell>
|
||||
<TableCell sx={cellStyle}>
|
||||
{row.cvssScore === 0 ? 'N/A' : row.cvssScore}
|
||||
</TableCell>
|
||||
<TableCell sx={cellStyle}>{row.lastModifiedDate}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/app/api/auth/login/route.ts
Normal file
37
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import axios from 'axios';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { username, password } = await req.json();
|
||||
|
||||
try {
|
||||
const backendRes = await axios.post(
|
||||
`${process.env.API_BASE_URL}/auth/login`,
|
||||
{ username, password },
|
||||
{
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// ✅ Optional — only if your backend uses this
|
||||
'X-Dev-Client': 'localhost',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const setCookieHeader = backendRes.headers['set-cookie'];
|
||||
|
||||
if (!setCookieHeader) {
|
||||
console.error('❌ No Set-Cookie header received from backend');
|
||||
return NextResponse.json({ error: 'Authentication failed' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ✅ Let the browser receive and store the cookie set by Spring Boot
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.headers.set('Set-Cookie', setCookieHeader.join(','));
|
||||
return response;
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('[LOGIN ROUTE] Login failed:', err.message || err);
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
17
src/app/api/auth/logout/route.ts
Normal file
17
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
response.cookies.set({
|
||||
name: 'authToken',
|
||||
value: '',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
sameSite: 'none', // match login cookie
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
146
src/app/globals.css
Normal file
146
src/app/globals.css
Normal file
@@ -0,0 +1,146 @@
|
||||
/* === Modern CSS Reset === */
|
||||
/* Based on Josh Comeau + Andy Bell best practices */
|
||||
|
||||
/* Tailwind utilities */
|
||||
@import "tailwindcss/preflight";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
/* Hide blinking caret for non-input areas */
|
||||
body {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
/* Allow normal caret inside editable fields */
|
||||
input, textarea, [contenteditable="true"] {
|
||||
caret-color: auto;
|
||||
}
|
||||
|
||||
/* Full height layout */
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Media elements */
|
||||
img, picture, video, canvas, svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Inputs and buttons */
|
||||
input, textarea, select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Headings and text */
|
||||
h1, h2, h3, h4, h5, h6, p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
display: inline !important;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
margin: 0 .05em 0 .1em;
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
|
||||
/* === Custom Login Styling === */
|
||||
.login-layout-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #838383;
|
||||
}
|
||||
|
||||
html.dark .login-layout-wrapper {
|
||||
background-color: #0d1117;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
background-color: #51509c;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
html.dark .login-wrapper {
|
||||
background-color: #1e293b;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
/* === Custom Login Form Enhancements === */
|
||||
|
||||
.login-wrapper form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem; /* Creates consistent spacing between form elements */
|
||||
}
|
||||
|
||||
.login-wrapper input,
|
||||
.login-wrapper button {
|
||||
width: 100%;
|
||||
box-sizing: border-box; /* Ensures padding/borders don't affect width */
|
||||
}
|
||||
|
||||
.login-wrapper input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #d1d5db; /* gray-300 */
|
||||
background-color: #fff;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
html.dark .login-wrapper input {
|
||||
background-color: #374151; /* gray-700 */
|
||||
border-color: #4b5563;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.login-wrapper button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
background-color: #2563eb; /* blue-600 */
|
||||
color: #fff;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-wrapper button:hover {
|
||||
background-color: #1d4ed8; /* Tailwind blue-700 */
|
||||
}
|
||||
|
||||
.login-wrapper button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
26
src/app/layout.tsx
Normal file
26
src/app/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { disableConsoleInProd } from '@/lib/disableConsole';
|
||||
disableConsoleInProd();
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'PSG - Oversight',
|
||||
description: 'Fisher Price "My First Vulnerability Assessment Tool"',
|
||||
};
|
||||
|
||||
// 🛡️ Console log disabling block
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'development') {
|
||||
console.log = () => {};
|
||||
console.debug = () => {};
|
||||
console.info = () => {};
|
||||
// You can leave console.error and console.warn working if you want.
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
252
src/app/login/page.tsx
Normal file
252
src/app/login/page.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
// src/app/login/page.tsx
|
||||
'use client';
|
||||
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { encrypt } from '@/app/utils/encryption';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Container,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
Snackbar,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [toast, setToast] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshAuth } = useAuth();
|
||||
|
||||
const reason = searchParams.get('reason');
|
||||
|
||||
useEffect(() => {
|
||||
if (!reason) return;
|
||||
|
||||
switch (reason) {
|
||||
case 'session-expired':
|
||||
setToast('Your session has expired. Please log in again.');
|
||||
break;
|
||||
case 'unauthorized':
|
||||
setToast('You must be logged in to view that page.');
|
||||
break;
|
||||
case 'invalid-token':
|
||||
setToast('Login token was invalid. Please sign in again.');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove the query param
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('reason');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
}, [reason]);
|
||||
|
||||
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const encryptedUsername = encrypt(username);
|
||||
const encryptedPassword = encrypt(password);
|
||||
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json',
|
||||
'X-Dev-Client': 'localhost',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: encryptedUsername,
|
||||
password: encryptedPassword,
|
||||
}),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Login failed';
|
||||
try {
|
||||
const errJson = await response.json();
|
||||
errorMessage = errJson.error || errorMessage;
|
||||
} catch {
|
||||
// fallback in case it wasn't JSON
|
||||
errorMessage = await response.text();
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
|
||||
}
|
||||
|
||||
await response.json();
|
||||
|
||||
await refreshAuth(); // ✅ ensure state is set before redirect
|
||||
router.push('/dashboard'); // ✅ after context has full values
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
bgcolor: '#121212', // backgroundDark
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Snackbar
|
||||
open={!!toast}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setToast('')}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity="warning"
|
||||
onClose={() => setToast('')}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{toast}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
<Container maxWidth="xs">
|
||||
<Paper
|
||||
elevation={12}
|
||||
sx={{
|
||||
bgcolor: '#1e1e1e', // paperDark
|
||||
color: '#ffffff',
|
||||
p: 4,
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Box
|
||||
component="img"
|
||||
src="/logo.png"
|
||||
alt="Logo"
|
||||
sx={{ height: 96, width: 96, objectFit: 'contain', borderRadius: 2, boxShadow: 3 }}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<Typography
|
||||
variant="h4"
|
||||
fontWeight="bold"
|
||||
align="center"
|
||||
sx={{ color: '#ffffff' }}
|
||||
>
|
||||
Oversight Login
|
||||
</Typography>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: '100%' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleLogin}
|
||||
sx={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<TextField
|
||||
id="username"
|
||||
label="Username"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
sx={{
|
||||
input: { color: '#fff' },
|
||||
label: { color: '#aaa' },
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'& fieldset': { borderColor: '#4b5563' },
|
||||
'&:hover fieldset': { borderColor: '#2563eb' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#2563eb' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
id="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
sx={{
|
||||
input: { color: '#fff' },
|
||||
label: { color: '#aaa' },
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'& fieldset': { borderColor: '#4b5563' },
|
||||
'&:hover fieldset': { borderColor: '#2563eb' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#2563eb' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
sx={{
|
||||
bgcolor: '#2563eb',
|
||||
'&:hover': { bgcolor: '#1d4ed8' },
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
py: 1.5,
|
||||
}}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} color="inherit" /> : 'Login'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Suspense fallback={<div>Loading login page...</div>}>
|
||||
<LoginPageContent />
|
||||
</Suspense>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
15
src/app/page.tsx
Normal file
15
src/app/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
// src/app/page.tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function HomeRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.push('/login');
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
178
src/app/scripts/OneOffFullScrapeCVE.js
Normal file
178
src/app/scripts/OneOffFullScrapeCVE.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import axios from 'axios';
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
|
||||
dotenv.config({ path: '.env.local' });
|
||||
|
||||
function formatDate(isoString) {
|
||||
if (!isoString) return null;
|
||||
const date = new Date(isoString);
|
||||
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
function extractCpeParts(cpe) {
|
||||
const parts = cpe.split(':');
|
||||
return {
|
||||
vendor: parts[3] || null,
|
||||
product: parts[4] || null,
|
||||
version: parts[5] || null
|
||||
};
|
||||
}
|
||||
|
||||
function addDaysToISO(dateISO, days) {
|
||||
const date = new Date(dateISO);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function getInitialStartDate() {
|
||||
return '2002-01-01T00:00:00.000Z';
|
||||
}
|
||||
|
||||
function saveProgress(date) {
|
||||
fs.writeFileSync('cve_progress.txt', date, 'utf-8');
|
||||
}
|
||||
|
||||
function loadProgress() {
|
||||
return fs.existsSync('cve_progress.txt') ? fs.readFileSync('cve_progress.txt', 'utf-8') : getInitialStartDate();
|
||||
}
|
||||
|
||||
const DB = await mysql.createConnection({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
});
|
||||
|
||||
const BASE_URL = 'https://services.nvd.nist.gov/rest/json/cves/2.0';
|
||||
const API_KEY = process.env.NVD_API_KEY;
|
||||
const RESULTS_PER_PAGE = 2000;
|
||||
const MAX_RANGE_DAYS = 120;
|
||||
|
||||
async function fetchCVEPage(startIndex, startDate, endDate) {
|
||||
const queryParams = {
|
||||
startIndex,
|
||||
// resultsPerPage: RESULTS_PER_PAGE, // Optional: use default per NVD guidance
|
||||
pubStartDate: startDate,
|
||||
pubEndDate: endDate,
|
||||
};
|
||||
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
console.log(`🌐 Requesting: ${BASE_URL}?${queryString}`);
|
||||
|
||||
try {
|
||||
const res = await axios.get(BASE_URL, {
|
||||
params: queryParams,
|
||||
headers: API_KEY ? { apiKey: API_KEY } : {}
|
||||
});
|
||||
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
console.error(`❌ API error: ${err.response?.status} - ${err.response?.data?.message || err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function processCVE(cveWrapper) {
|
||||
const cve = cveWrapper.cve;
|
||||
const cveId = cve.id;
|
||||
const desc = cve.descriptions.find(d => d.lang === 'en')?.value ?? '';
|
||||
const published = formatDate(cve.published);
|
||||
const modified = formatDate(cve.lastModified);
|
||||
const severity = cve.metrics?.cvssMetricV31?.[0]?.cvssData?.baseSeverity ?? null;
|
||||
const score = cve.metrics?.cvssMetricV31?.[0]?.cvssData?.baseScore ?? null;
|
||||
|
||||
try {
|
||||
await DB.execute(
|
||||
`INSERT INTO cves (id, description, published_date, last_modified_date, severity, cvss_score)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE last_modified_date = VALUES(last_modified_date)`,
|
||||
[cveId, desc, published, modified, severity, score]
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`Error inserting CVE ${cveId}:`, err.message);
|
||||
}
|
||||
|
||||
const configurations = cve.configurations ?? [];
|
||||
for (const node of configurations) {
|
||||
for (const match of node.nodes?.flatMap(n => n.cpeMatch ?? []) ?? []) {
|
||||
const cpe = match.criteria;
|
||||
const vulnerable = match.vulnerable ? 1 : 0;
|
||||
const start = match.versionStartIncluding || match.versionStartExcluding || null;
|
||||
const end = match.versionEndIncluding || match.versionEndExcluding || null;
|
||||
|
||||
const { vendor, product, version } = extractCpeParts(cpe);
|
||||
|
||||
try {
|
||||
await DB.execute(
|
||||
`INSERT INTO cpe_matches (cve_id, cpe_uri, version_start, version_end, vulnerable, vendor, product, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[cveId, cpe, start, end, vulnerable, vendor, product, version]
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`Error inserting CPE for CVE ${cveId}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importFullCVEArchive() {
|
||||
let startDate = loadProgress();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
console.log(`📡 Starting full CVE sync from ${startDate} to ${now}`);
|
||||
|
||||
while (new Date(startDate) < new Date(now)) {
|
||||
const rawEndDate = addDaysToISO(startDate, MAX_RANGE_DAYS);
|
||||
const endDate = new Date(rawEndDate) > new Date(now) ? now : rawEndDate;
|
||||
|
||||
console.log(`🔍 Fetching published CVEs from ${startDate} to ${endDate}...`);
|
||||
|
||||
let startIndex = 0;
|
||||
let totalResults = Infinity;
|
||||
|
||||
try {
|
||||
while (startIndex < totalResults) {
|
||||
const data = await fetchCVEPage(startIndex, startDate, endDate);
|
||||
const vulnerabilities = data.vulnerabilities || [];
|
||||
totalResults = data.totalResults;
|
||||
|
||||
for (const vuln of vulnerabilities) {
|
||||
await processCVE(vuln);
|
||||
}
|
||||
|
||||
startIndex += RESULTS_PER_PAGE;
|
||||
console.log(`➡️ Fetched ${vulnerabilities.length} CVEs from index ${startIndex} (Total expected: ${totalResults})`);
|
||||
|
||||
// Respect NVD API rate limit
|
||||
await new Promise(r => setTimeout(r, 6000));
|
||||
}
|
||||
|
||||
// Only save progress if the chunk succeeded
|
||||
startDate = endDate;
|
||||
saveProgress(startDate);
|
||||
|
||||
} catch (err) {
|
||||
if (err.response?.status === 404) {
|
||||
console.warn(`⚠️ No CVEs found for range ${startDate} to ${endDate}. Skipping...`);
|
||||
} else {
|
||||
console.error(`❌ Fatal error at date range ${startDate} - ${endDate}:`, err.message);
|
||||
console.error(`⚠️ Progress saved. You can restart the script to resume.`);
|
||||
await DB.end();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Save progress even if it's a 404 so we don’t retry the same broken range
|
||||
startDate = endDate;
|
||||
saveProgress(startDate);
|
||||
|
||||
}
|
||||
|
||||
console.log('✅ CVE import complete!');
|
||||
await DB.end();
|
||||
}
|
||||
|
||||
importFullCVEArchive();
|
||||
147
src/app/scripts/fetchCVE.js
Normal file
147
src/app/scripts/fetchCVE.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import axios from 'axios';
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config({ path: '.env.local' });
|
||||
|
||||
function formatDate(isoString) {
|
||||
if (!isoString) return null;
|
||||
const date = new Date(isoString);
|
||||
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
function extractCpeParts(cpe) {
|
||||
const parts = cpe.split(':');
|
||||
return {
|
||||
vendor: parts[3] || null,
|
||||
product: parts[4] || null,
|
||||
version: parts[5] || null
|
||||
};
|
||||
}
|
||||
|
||||
function addDaysToISO(dateISO, days) {
|
||||
const date = new Date(dateISO);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
const DB = await mysql.createConnection({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
});
|
||||
|
||||
const BASE_URL = 'https://services.nvd.nist.gov/rest/json/cves/2.0';
|
||||
const API_KEY = process.env.NVD_API_KEY;
|
||||
const RESULTS_PER_PAGE = 2000;
|
||||
const MAX_RANGE_DAYS = 120;
|
||||
|
||||
async function fetchCVEPage(startIndex, startDate, endDate) {
|
||||
try {
|
||||
const res = await axios.get(BASE_URL, {
|
||||
params: {
|
||||
startIndex,
|
||||
resultsPerPage: RESULTS_PER_PAGE,
|
||||
lastModStartDate: startDate,
|
||||
lastModEndDate: endDate,
|
||||
},
|
||||
headers: API_KEY ? { apiKey: API_KEY } : {}
|
||||
});
|
||||
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
console.error(`❌ API error: ${err.response?.status} - ${err.response?.data?.message || err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function processCVE(cveWrapper) {
|
||||
const cve = cveWrapper.cve;
|
||||
const cveId = cve.id;
|
||||
const desc = cve.descriptions.find(d => d.lang === 'en')?.value ?? '';
|
||||
const published = formatDate(cve.published);
|
||||
const modified = formatDate(cve.lastModified);
|
||||
const severity = cve.metrics?.cvssMetricV31?.[0]?.cvssData?.baseSeverity ?? null;
|
||||
const score = cve.metrics?.cvssMetricV31?.[0]?.cvssData?.baseScore ?? null;
|
||||
|
||||
try {
|
||||
await DB.execute(
|
||||
`INSERT INTO cves (id, description, published_date, last_modified_date, severity, cvss_score)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE last_modified_date = VALUES(last_modified_date)`,
|
||||
[cveId, desc, published, modified, severity, score]
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`Error inserting CVE ${cveId}:`, err.message);
|
||||
}
|
||||
|
||||
const configurations = cve.configurations ?? [];
|
||||
for (const node of configurations) {
|
||||
for (const match of node.nodes?.flatMap(n => n.cpeMatch ?? []) ?? []) {
|
||||
const cpe = match.criteria;
|
||||
const vulnerable = match.vulnerable ? 1 : 0;
|
||||
const start = match.versionStartIncluding || match.versionStartExcluding || null;
|
||||
const end = match.versionEndIncluding || match.versionEndExcluding || null;
|
||||
|
||||
const { vendor, product, version } = extractCpeParts(cpe);
|
||||
|
||||
try {
|
||||
await DB.execute(
|
||||
`INSERT INTO cpe_matches (cve_id, cpe_uri, version_start, version_end, vulnerable, vendor, product, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[cveId, cpe, start, end, vulnerable, vendor, product, version]
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`Error inserting CPE for CVE ${cveId}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getMostRecentModifiedDateFromDB() {
|
||||
const [rows] = await DB.query(`SELECT MAX(last_modified_date) AS lastMod FROM cves`);
|
||||
const lastMod = rows[0]?.lastMod;
|
||||
return lastMod ? new Date(lastMod).toISOString() : '2020-01-01T00:00:00.000Z';
|
||||
}
|
||||
|
||||
async function importCVEFeed() {
|
||||
let startDate = await getMostRecentModifiedDateFromDB();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
console.log(`📡 Starting CVE sync from ${startDate} to ${now}`);
|
||||
|
||||
while (new Date(startDate) < new Date(now)) {
|
||||
const endDate = addDaysToISO(startDate, MAX_RANGE_DAYS);
|
||||
console.log(`🔍 Fetching modified CVEs from ${startDate} to ${endDate}...`);
|
||||
|
||||
let startIndex = 0;
|
||||
let totalResults = Infinity;
|
||||
|
||||
while (startIndex < totalResults) {
|
||||
const data = await fetchCVEPage(startIndex, startDate, endDate);
|
||||
const vulnerabilities = data.vulnerabilities || [];
|
||||
totalResults = data.totalResults;
|
||||
|
||||
for (const vuln of vulnerabilities) {
|
||||
await processCVE(vuln);
|
||||
}
|
||||
|
||||
startIndex += RESULTS_PER_PAGE;
|
||||
console.log(`➡️ Fetched ${vulnerabilities.length} CVEs from index ${startIndex} (Total expected: ${totalResults})`);
|
||||
|
||||
|
||||
// Respect NVD API rate limit: max 10 requests/min without key
|
||||
await new Promise(r => setTimeout(r, 6000));
|
||||
}
|
||||
|
||||
startDate = endDate;
|
||||
}
|
||||
|
||||
console.log('✅ CVE import complete!');
|
||||
await DB.end();
|
||||
}
|
||||
|
||||
importCVEFeed().catch(err => {
|
||||
console.error('❌ Fatal error during import:', err.message);
|
||||
});
|
||||
125
src/app/scripts/importCVE.js
Normal file
125
src/app/scripts/importCVE.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import JSONStream from 'jsonstream';
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config({ path: '.env.local' });
|
||||
|
||||
function formatDate(isoString) {
|
||||
if (!isoString) return null;
|
||||
const date = new Date(isoString);
|
||||
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
function extractCpeParts(cpe) {
|
||||
// cpe:2.3:a:vendor:product:version:update:...
|
||||
const parts = cpe.split(':');
|
||||
return {
|
||||
vendor: parts[3] || null,
|
||||
product: parts[4] || null,
|
||||
version: parts[5] || null
|
||||
};
|
||||
}
|
||||
|
||||
const DB = await mysql.createConnection({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
});
|
||||
|
||||
const LOCAL_FEED_FILE = path.join(
|
||||
process.cwd(),
|
||||
'src',
|
||||
'app',
|
||||
'scripts',
|
||||
'json_feeds',
|
||||
'nvdcve-1.1-2024.json.gz'
|
||||
);
|
||||
|
||||
// Counter to track pending inserts
|
||||
let pendingQueries = 0;
|
||||
let resolveWhenDone;
|
||||
const done = new Promise(resolve => (resolveWhenDone = resolve));
|
||||
|
||||
async function processCVE(cve) {
|
||||
const cveId = cve.cve.CVE_data_meta.ID;
|
||||
const desc = cve.cve.description.description_data[0]?.value ?? '';
|
||||
const published = formatDate(cve.publishedDate);
|
||||
const modified = formatDate(cve.lastModifiedDate);
|
||||
const severity = cve.impact?.baseMetricV3?.cvssV3?.baseSeverity ?? null;
|
||||
const score = cve.impact?.baseMetricV3?.cvssV3?.baseScore ?? null;
|
||||
|
||||
try {
|
||||
await DB.execute(
|
||||
`INSERT INTO cves (id, description, published_date, last_modified_date, severity, cvss_score)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE last_modified_date = VALUES(last_modified_date)`,
|
||||
[cveId, desc, published, modified, severity, score]
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`Error inserting CVE ${cveId}:`, err.message);
|
||||
}
|
||||
|
||||
const nodes = cve.configurations?.nodes ?? [];
|
||||
for (const node of nodes) {
|
||||
for (const match of node.cpe_match ?? []) {
|
||||
const cpe = match.cpe23Uri;
|
||||
const vulnerable = match.vulnerable ? 1 : 0;
|
||||
const start = match.versionStartIncluding || match.versionStartExcluding || null;
|
||||
const end = match.versionEndIncluding || match.versionEndExcluding || null;
|
||||
|
||||
const { vendor, product, version } = extractCpeParts(cpe);
|
||||
|
||||
try {
|
||||
await DB.execute(
|
||||
`INSERT INTO cpe_matches (cve_id, cpe_uri, version_start, version_end, vulnerable, vendor, product, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[cveId, cpe, start, end, vulnerable, vendor, product, version]
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`Error inserting CPE for CVE ${cveId}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importCVEFeed() {
|
||||
console.log('📦 Reading local feed file:', LOCAL_FEED_FILE);
|
||||
|
||||
const fileStream = fs.createReadStream(LOCAL_FEED_FILE);
|
||||
const gunzip = zlib.createGunzip();
|
||||
const parser = JSONStream.parse('CVE_Items.*');
|
||||
|
||||
parser.on('data', (cve) => {
|
||||
pendingQueries++;
|
||||
processCVE(cve).finally(() => {
|
||||
pendingQueries--;
|
||||
if (pendingQueries === 0) resolveWhenDone();
|
||||
});
|
||||
});
|
||||
|
||||
parser.on('error', err => {
|
||||
console.error('❌ JSONStream parse error:', err.message);
|
||||
});
|
||||
|
||||
try {
|
||||
await pipeline(fileStream, gunzip, parser);
|
||||
} catch (err) {
|
||||
console.error('❌ Pipeline error:', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('⏳ Waiting for all inserts to complete...');
|
||||
await done;
|
||||
|
||||
console.log('✅ Import complete!');
|
||||
await DB.end();
|
||||
}
|
||||
|
||||
importCVEFeed().catch(err => {
|
||||
console.error('❌ Fatal error during import:', err.message);
|
||||
});
|
||||
BIN
src/app/scripts/json_feeds/nvdcve-1.1-2024.json.gz
Normal file
BIN
src/app/scripts/json_feeds/nvdcve-1.1-2024.json.gz
Normal file
Binary file not shown.
BIN
src/app/scripts/json_feeds/nvdcve-1.1-2025.json.gz
Normal file
BIN
src/app/scripts/json_feeds/nvdcve-1.1-2025.json.gz
Normal file
Binary file not shown.
BIN
src/app/scripts/json_feeds/nvdcve-2020trans.xml.gz
Normal file
BIN
src/app/scripts/json_feeds/nvdcve-2020trans.xml.gz
Normal file
Binary file not shown.
BIN
src/app/scripts/json_feeds/nvdcve-2021trans.xml.gz
Normal file
BIN
src/app/scripts/json_feeds/nvdcve-2021trans.xml.gz
Normal file
Binary file not shown.
BIN
src/app/scripts/json_feeds/nvdcve-2022trans.xml.gz
Normal file
BIN
src/app/scripts/json_feeds/nvdcve-2022trans.xml.gz
Normal file
Binary file not shown.
BIN
src/app/scripts/json_feeds/nvdcve-2023trans.xml.gz
Normal file
BIN
src/app/scripts/json_feeds/nvdcve-2023trans.xml.gz
Normal file
Binary file not shown.
24
src/app/styles/colors.ts
Normal file
24
src/app/styles/colors.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/styles/colors.ts
|
||||
export const brandColors = {
|
||||
primary: '#2563eb', // Tailwind blue-600
|
||||
secondary: '#9333ea', // Tailwind purple-600
|
||||
backgroundLight: '#f9fafb', // Tailwind gray-50 (even lighter)
|
||||
backgroundDark: '#121212', // Dark mode background
|
||||
paperLight: '#ffffff',
|
||||
paperDark: '#1e1e1e',
|
||||
textLight: '#1f2937', // Tailwind gray-800
|
||||
textDark: '#ffffff', // Pure white text
|
||||
drawerDark: '#121212',
|
||||
selectedLight: '#d1d5db', // Tailwind gray-300 (more visible)
|
||||
selectedDark: '#2a2a2a', // Good for selected items in dark mode
|
||||
linkLight: '#2563eb', // or any Tailwind-appropriate color
|
||||
linkDark: '#93c5fd', // lighter blue for contrast
|
||||
surfaceLight: '#ffffff', // Card/Dialog surfaces (renamed from "paperLight")
|
||||
surfaceDark: '#1e1e1e', // Card/Dialog surfaces dark mode
|
||||
linkColor: '#2563eb', // Use primary for links
|
||||
errorColor: '#dc2626', // Tailwind red-600 (errors/critical severity)
|
||||
warningColor: '#f59e0b', // Tailwind amber-500 (warnings/high severity)
|
||||
successColor: '#16a34a', // Tailwind green-600 (success/low severity)
|
||||
infoColor: '#0ea5e9', // Tailwind sky-500 (informational/medium severity)
|
||||
};
|
||||
|
||||
14
src/app/utils/encryption.ts
Normal file
14
src/app/utils/encryption.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// src/utils/encryption.ts
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
const KEY = CryptoJS.enc.Utf8.parse("HWJGbwmF2pWdXySDExMNEbJSrXn0YCBF");
|
||||
const IV = CryptoJS.enc.Utf8.parse("VWYRtYCfch0sKs6k");
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const encrypted = CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(text), KEY, {
|
||||
iv: IV,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7,
|
||||
});
|
||||
return encrypted.toString(); // Base64
|
||||
}
|
||||
14
src/app/utils/passwordStrength.ts
Normal file
14
src/app/utils/passwordStrength.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function getPasswordStrength(password: string): 'Weak' | 'Medium' | 'Strong' {
|
||||
const hasLetters = /[a-zA-Z]/.test(password);
|
||||
const hasNumbers = /[0-9]/.test(password);
|
||||
const hasSymbols = /[^a-zA-Z0-9]/.test(password);
|
||||
|
||||
if (password.length >= 12 && hasLetters && hasNumbers && hasSymbols) {
|
||||
return 'Strong';
|
||||
} else if (password.length >= 8 && hasLetters && hasNumbers) {
|
||||
return 'Medium';
|
||||
} else {
|
||||
return 'Weak';
|
||||
}
|
||||
}
|
||||
|
||||
12
src/app/utils/stringAvatar.ts
Normal file
12
src/app/utils/stringAvatar.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// utils/stringAvatar.ts
|
||||
import stringToColor from './stringToColor';
|
||||
|
||||
export default function stringAvatar(name: string) {
|
||||
const firstChar = name?.trim()?.charAt(0)?.toUpperCase() || '?';
|
||||
return {
|
||||
sx: {
|
||||
bgcolor: stringToColor(name || 'User'),
|
||||
},
|
||||
children: firstChar,
|
||||
};
|
||||
}
|
||||
16
src/app/utils/stringToColor.ts
Normal file
16
src/app/utils/stringToColor.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// utils/stringToColor.ts
|
||||
export default function stringToColor(name: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
let color = '#';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += (`00${value.toString(16)}`).slice(-2);
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
44
src/components/AuthGuard.tsx
Normal file
44
src/components/AuthGuard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
const PUBLIC_ROUTES = ['/login', '/register', '/forgot-password'];
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { username, loading } = useAuth();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const isAuthenticated = !!username && username !== 'user';
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
console.log("🛡 AuthGuard username:", username, "| loading:", loading);
|
||||
|
||||
|
||||
if (PUBLIC_ROUTES.includes(pathname)) {
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
router.replace('/login?reason=unauthorized');
|
||||
} else {
|
||||
setIsReady(true);
|
||||
}
|
||||
|
||||
}, [pathname, loading, username]);
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div style={{ display: 'grid', placeItems: 'center', height: '100vh' }}>
|
||||
<span style={{ color: '#fff' }}>Checking auth...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
99
src/components/CVELogStream.tsx
Normal file
99
src/components/CVELogStream.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Box, Typography, IconButton } from '@mui/material';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import { parseEmojis } from '@/components/Twemoji';
|
||||
import api from '@/lib/axios';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
streamUrl: string; // 👈 New: URL to stream logs from
|
||||
title: string; // 👈 New: Console title
|
||||
clearUrl?: string; // 👈 Optional: if you want a clear logs endpoint
|
||||
};
|
||||
|
||||
export default function CVELogStream({ visible, streamUrl, title, clearUrl }: Props) {
|
||||
const [logOutput, setLogOutput] = useState('');
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const eventSource = new EventSource(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}${streamUrl}`,
|
||||
{ withCredentials: true } as unknown as EventSourceInit
|
||||
);
|
||||
|
||||
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
setLogOutput(prev => prev + '\n' + event.data);
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error('❌ SSE connection error', err);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [visible, streamUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logOutput]);
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
if (!clearUrl) return;
|
||||
try {
|
||||
await api.post(clearUrl);
|
||||
setLogOutput('📭 Logs cleared.');
|
||||
} catch (err) {
|
||||
setLogOutput('❌ Failed to clear logs.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: '#121212',
|
||||
color: '#00FF00',
|
||||
padding: 2,
|
||||
borderRadius: 2,
|
||||
fontFamily: `'Source Code Pro', 'Fira Code', monospace`,
|
||||
fontSize: '0.85rem',
|
||||
height: '400px',
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
position: 'relative',
|
||||
mt: 4,
|
||||
}}
|
||||
>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{clearUrl && (
|
||||
<IconButton onClick={handleClearLogs} size="small" sx={{ color: 'white' }}>
|
||||
<RestartAltIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: parseEmojis(
|
||||
logOutput.trim()
|
||||
? logOutput
|
||||
: '📭 No log output yet. Please wait for sync to begin or check backend logs.'
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div ref={logEndRef} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
188
src/components/ChangePasswordDrawer.tsx
Normal file
188
src/components/ChangePasswordDrawer.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Drawer,
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
IconButton,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { encrypt } from '@/app/utils/encryption';
|
||||
import { getPasswordStrength } from '@/app/utils/passwordStrength';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const ChangePasswordDrawer = ({
|
||||
open,
|
||||
onClose
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const { username, authToken } = useAuth();
|
||||
//console.log('🧪 username:', username);
|
||||
//console.log('🧪 authToken:', authToken);
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
|
||||
const strength = getPasswordStrength(newPassword);
|
||||
const strengthValue = strength === 'Weak' ? 30 : strength === 'Medium' ? 60 : 100;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) resetForm();
|
||||
}, [open]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setMessage('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage('');
|
||||
setError('');
|
||||
|
||||
if (!username) {
|
||||
setError('User not authenticated.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
currentPassword: encrypt(currentPassword),
|
||||
newPassword: encrypt(newPassword),
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (res.ok) {
|
||||
setMessage(text);
|
||||
} else {
|
||||
setError(text);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Drawer anchor="left" open={open} onClose={onClose} transitionDuration={300}>
|
||||
<Box sx={{ width: 350, p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6">Change Password</Typography>
|
||||
<IconButton onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
type="password"
|
||||
label="Current Password"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="password"
|
||||
label="New Password"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Password Strength Bar */}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption">Strength: {strength}</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={strengthValue}
|
||||
color={
|
||||
strength === 'Strong'
|
||||
? 'success'
|
||||
: strength === 'Medium'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
}
|
||||
sx={{ height: 6, borderRadius: 2, mt: 0.5 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
type="password"
|
||||
label="Confirm New Password"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
disabled={username === 'testuser'}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
|
||||
{username === 'testuser' && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
p: 1.5,
|
||||
borderRadius: 1,
|
||||
borderLeft: '4px solid #0288d1',
|
||||
fontSize: '0.875rem',
|
||||
color: '#333',
|
||||
}}
|
||||
>
|
||||
Password changes are disabled for this user.
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
{message && <Alert severity="success" sx={{ mt: 2 }}>{message}</Alert>}
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordDrawer;
|
||||
68
src/components/DeviceDropdown.tsx
Normal file
68
src/components/DeviceDropdown.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// src/components/DeviceDropdown.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
interface DeviceDropdownProps {
|
||||
devices: { deviceId: number; hostname: string | null }[];
|
||||
selectedDevice: number | null;
|
||||
setSelectedDevice: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export default function DeviceDropdown({
|
||||
devices,
|
||||
selectedDevice,
|
||||
setSelectedDevice,
|
||||
}: DeviceDropdownProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
const handleChange = (event: SelectChangeEvent<string>) => {
|
||||
const value = event.target.value;
|
||||
setSelectedDevice(value === '' ? null : Number(value));
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl fullWidth size="small" sx={{ minWidth: 240 }}>
|
||||
<InputLabel id="device-select-label" sx={{ color: theme.palette.text.primary }}>
|
||||
Select Device
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="device-select-label"
|
||||
id="device-select"
|
||||
value={selectedDevice?.toString() ?? ''}
|
||||
label="Select Device"
|
||||
onChange={handleChange}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
{devices.map((device) => (
|
||||
<MenuItem key={device.deviceId} value={device.deviceId}>
|
||||
{device.hostname || `Device #${device.deviceId}`}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
246
src/components/DeviceListSidebar.tsx
Normal file
246
src/components/DeviceListSidebar.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
// src/components/DeviceListSidebar.tsx
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { List, ListItemButton, ListItemIcon, ListItemText, Typography, Box,TextField } from '@mui/material';
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||
import { parseISO, formatDistanceToNowStrict } from 'date-fns';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { DetailedDevice } from '@/types/devices';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
|
||||
|
||||
interface DeviceListSidebarProps {
|
||||
devices: DetailedDevice[];
|
||||
selectedDevice: number | null;
|
||||
onSelect: (deviceId: number) => void;
|
||||
open: boolean;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
pinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}
|
||||
|
||||
|
||||
const ONLINE_THRESHOLD_MINUTES = 1440;
|
||||
|
||||
|
||||
|
||||
|
||||
function getStatusColor(lastCheckedIn: string) {
|
||||
const lastSeen = parseISO(lastCheckedIn);
|
||||
const minutesAgo = (Date.now() - lastSeen.getTime()) / 1000 / 60;
|
||||
return minutesAgo < ONLINE_THRESHOLD_MINUTES ? 'green' : 'grey';
|
||||
}
|
||||
|
||||
function formatRelativeTime(lastCheckedIn: string) {
|
||||
try {
|
||||
return formatDistanceToNowStrict(parseISO(lastCheckedIn), { addSuffix: true });
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceListSidebar: React.FC<DeviceListSidebarProps> = ({
|
||||
devices,
|
||||
selectedDevice,
|
||||
onSelect,
|
||||
open,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
}) => {
|
||||
const [sortAsc, setSortAsc] = React.useState(true);
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
|
||||
<Box
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: '100vh',
|
||||
width: open ? 200 : 200,
|
||||
minWidth: open ? 200 : 200,
|
||||
transition: 'width 0.3s ease',
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #ccc',
|
||||
bgcolor: 'background.paper',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Search devices..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
variant="outlined"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{devices.length === 0 && (
|
||||
<Typography sx={{ px: 2, py: 1, color: 'error.main' }}>
|
||||
No devices received (length: 0)
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start', // 👈 always left-aligned
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
noWrap
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
opacity: open ? 1 : 1,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Select a Device
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSortAsc((prev) => !prev)}
|
||||
sx={{ ml: 'auto' }} // Pushes it to the right
|
||||
>
|
||||
<Tooltip title={`Sort by Hostname (${sortAsc ? 'A-Z' : 'Z-A'})`}>
|
||||
{sortAsc ? <ArrowUpwardIcon fontSize="small" /> : <ArrowDownwardIcon fontSize="small" />}
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
{open && onTogglePin && (
|
||||
<Tooltip title={pinned ? 'Unpin Sidebar' : 'Pin Sidebar'}>
|
||||
<IconButton
|
||||
size="medium"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePin?.();
|
||||
}}
|
||||
sx={(theme) => ({
|
||||
position: 'absolute',
|
||||
right: -10,
|
||||
top: 'calc(50% - 64px)', // offsets for AppBar height
|
||||
transform: 'translateY(-50%)',
|
||||
backgroundColor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
transition: 'background-color 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
})}
|
||||
|
||||
>
|
||||
{pinned ? <ChevronLeftIcon /> : <ChevronRightIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
|
||||
{/* Device list */}
|
||||
<List dense={!open}>
|
||||
{[...devices]
|
||||
.sort((a, b) =>
|
||||
sortAsc
|
||||
? a.hostname.localeCompare(b.hostname)
|
||||
: b.hostname.localeCompare(a.hostname)
|
||||
)
|
||||
.filter((device) =>
|
||||
device.hostname.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
.map((device) => (
|
||||
|
||||
<ListItemButton
|
||||
key={device.deviceId}
|
||||
selected={device.deviceId === selectedDevice}
|
||||
onClick={() => onSelect(device.deviceId)}
|
||||
sx={{
|
||||
alignItems: 'flex-start',
|
||||
minHeight: 56,
|
||||
px: open ? 2 : 1.5,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
mr: open ? 1.5 : 1,
|
||||
mt: 0.5,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<FiberManualRecordIcon sx={{ color: getStatusColor(device.lastCheckedIn), fontSize: 12 }} />
|
||||
</ListItemIcon>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
minWidth: 0, // 🚨 ensures text truncation works
|
||||
}}
|
||||
>
|
||||
{/* Hostname: Always visible */}
|
||||
<Typography
|
||||
noWrap
|
||||
title={device.hostname}
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
{device.hostname}
|
||||
</Typography>
|
||||
|
||||
{/* Last seen: Only visible when expanded */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
opacity: open ? 1 : 0,
|
||||
maxHeight: open ? 16 : 0,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'opacity 0.2s ease, max-height 0.2s ease',
|
||||
}}
|
||||
>
|
||||
🕒 {formatRelativeTime(device.lastCheckedIn)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
export default DeviceListSidebar;
|
||||
46
src/components/Providers.tsx
Normal file
46
src/components/Providers.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AuthProvider, useAuth } from '@/context/AuthContext';
|
||||
import { DeviceProvider } from '@/context/DeviceContext';
|
||||
import AppThemeProvider from '@/components/providers/AppThemeProvider';
|
||||
import AuthGuard from '@/components/AuthGuard'; // ✅ Add this import
|
||||
|
||||
function InnerProviders({ children }: { children: React.ReactNode }) {
|
||||
const { username, loading } = useAuth();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
const isAuthenticated = !!username && username !== 'user';
|
||||
|
||||
return (
|
||||
<AppThemeProvider>
|
||||
{isAuthenticated ? (
|
||||
<DeviceProvider>{children}</DeviceProvider>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AppThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Providers({
|
||||
children,
|
||||
username,
|
||||
displayname,
|
||||
roles = [],
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
username: string;
|
||||
displayname: string;
|
||||
roles?: string[];
|
||||
}) {
|
||||
return (
|
||||
<AuthProvider username={username} displayname={displayname} roles={roles}>
|
||||
{/* ✅ Enforce auth globally (but client-side only) */}
|
||||
<AuthGuard>
|
||||
<InnerProviders>{children}</InnerProviders>
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
341
src/components/SidebarLayout.tsx
Normal file
341
src/components/SidebarLayout.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTheme, styled, CSSObject, Theme } from '@mui/material/styles';
|
||||
import {
|
||||
AppBar as MuiAppBar,
|
||||
Toolbar,
|
||||
Drawer as MuiDrawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Box,
|
||||
Typography,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Avatar,
|
||||
CssBaseline,
|
||||
Collapse
|
||||
} from '@mui/material';
|
||||
import Link from 'next/link';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import DevicesIcon from '@mui/icons-material/Devices';
|
||||
import AppsIcon from '@mui/icons-material/Apps';
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
import ChangePasswordDrawer from '@/components/ChangePasswordDrawer';
|
||||
import { useThemeMode } from '@/context/ThemeContext';
|
||||
import { getUserInfoFromToken, getStoredToken } from '@/lib/auth';
|
||||
import UserMenu from '@/components/UserMenu';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
const collapsedDrawerWidth = 65;
|
||||
const drawerWidth = 240;
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
|
||||
const openedMixin = (theme: Theme): CSSObject => ({
|
||||
width: drawerWidth,
|
||||
transition: theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
overflowX: 'hidden',
|
||||
});
|
||||
|
||||
const closedMixin = (theme: Theme): CSSObject => ({
|
||||
transition: theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
overflowX: 'hidden',
|
||||
width: `${collapsedDrawerWidth}px`,
|
||||
});
|
||||
|
||||
|
||||
const DrawerHeader = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
padding: theme.spacing(0, 1),
|
||||
...theme.mixins.toolbar,
|
||||
}));
|
||||
|
||||
const AppBar = styled(MuiAppBar, {
|
||||
shouldForwardProp: (prop) => prop !== 'open',
|
||||
})<{ open?: boolean }>(({ theme, open }) => ({
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
marginLeft: open ? drawerWidth : collapsedDrawerWidth,
|
||||
width: open
|
||||
? `calc(100% - ${drawerWidth}px)`
|
||||
: `calc(100% - ${collapsedDrawerWidth}px)`,
|
||||
transition: theme.transitions.create(['width', 'margin'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: open
|
||||
? theme.transitions.duration.enteringScreen
|
||||
: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
||||
const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })(
|
||||
({ theme, open }) => ({
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
boxSizing: 'border-box',
|
||||
...(open && {
|
||||
...openedMixin(theme),
|
||||
'& .MuiDrawer-paper': openedMixin(theme),
|
||||
}),
|
||||
...(!open && {
|
||||
...closedMixin(theme),
|
||||
'& .MuiDrawer-paper': closedMixin(theme),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
export default function SidebarLayout({ children }: { children: React.ReactNode }) {
|
||||
const theme = useTheme();
|
||||
const { darkMode, toggle } = useThemeMode();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [stayExpanded, setStayExpanded] = useState(false);
|
||||
const [hoverTimer, setHoverTimer] = useState<NodeJS.Timeout | null>(null);
|
||||
const [adminOpen, setAdminOpen] = useState(false);
|
||||
const { username, displayname, loading, roles } = useAuth();
|
||||
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
//console.log('AuthContext values:', { username, displayname, roles, loading });
|
||||
const mainNavItems = [
|
||||
{ label: 'Dashboard', path: '/dashboard', icon: <DashboardIcon /> },
|
||||
{ label: 'Vulnerabilities', path: '/vulnerabilities', icon: <SecurityIcon /> },
|
||||
{ label: 'Devices', path: '/devices', icon: <DevicesIcon /> },
|
||||
{ label: 'Software', path: '/software', icon: <AppsIcon /> },
|
||||
];
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.href = '/login?reason=session-expired';
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
window.location.href = '/login?reason=unauthorized';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('drawerPinned') === 'true';
|
||||
setStayExpanded(saved);
|
||||
setOpen(saved);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const toggleDrawerPin = () => {
|
||||
const newState = !stayExpanded;
|
||||
setStayExpanded(newState);
|
||||
setOpen(newState);
|
||||
localStorage.setItem('drawerPinned', String(newState));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column' }}>
|
||||
<CssBaseline />
|
||||
|
||||
{/* AppBar stays on top */}
|
||||
<AppBar position="fixed" open={open} color="default" elevation={1}>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
px: 3,
|
||||
minHeight: 48,
|
||||
bgcolor: 'background.default',
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
{/* Just a spacer */}
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{/* Right-aligned build label */}
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: isDev ? 'error.main' : 'success.main',
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{isDev ? 'DEVELOPMENT BUILD' : 'LIVE BUILD'}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
|
||||
</AppBar>
|
||||
{/* This wrapper handles sidebar + main content horizontally */}
|
||||
<Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0 }}>
|
||||
{/* Sidebar (Drawer) */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
open={open}
|
||||
onMouseEnter={() => {
|
||||
if (!stayExpanded) {
|
||||
const timer = setTimeout(() => setOpen(true), 200);
|
||||
setHoverTimer(timer);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!stayExpanded) {
|
||||
if (hoverTimer) clearTimeout(hoverTimer);
|
||||
const timer = setTimeout(() => setOpen(false), 200);
|
||||
setHoverTimer(timer);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiDrawer-paper': {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<DrawerHeader sx={{ justifyContent: open ? 'flex-start' : 'center', px: 2 }}>
|
||||
<Box component={Link} href="/" sx={{ display: 'flex', alignItems: 'center', textDecoration: 'none' }}>
|
||||
<Box
|
||||
component="img"
|
||||
src="/logo_sidebar.png"
|
||||
alt="Logo"
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
mx: 'auto', // center horizontally
|
||||
transition: 'transform 0.2s',
|
||||
transform: open ? 'scale(1)' : 'scale(0.85)',
|
||||
}}
|
||||
/>
|
||||
{open && <Typography variant="h6" noWrap sx={{ ml: 1, fontWeight: 600, color: 'text.primary' }}>Oversight</Typography>}
|
||||
</Box>
|
||||
{open && (
|
||||
<Tooltip title={stayExpanded ? "Unpin Drawer" : "Pin Drawer"}>
|
||||
<IconButton
|
||||
size="medium"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleDrawerPin();
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute', right: -10, top: '50%', transform: 'translateY(-50%)', backgroundColor: 'background.paper',
|
||||
border: '1px solid', borderColor: 'divider', borderRadius: '0 4px 4px 0', zIndex: theme.zIndex.drawer + 1,
|
||||
transition: 'background-color 0.2s ease-in-out',
|
||||
'&:hover': { backgroundColor: theme.palette.action.hover },
|
||||
}}
|
||||
>
|
||||
{stayExpanded ? <ChevronLeftIcon /> : <ChevronRightIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</DrawerHeader>
|
||||
|
||||
<List>
|
||||
{mainNavItems.map(({ label, path, icon }) => (
|
||||
<ListItem key={label} disablePadding sx={{ display: 'block' }}>
|
||||
<ListItemButton component={Link} href={path} sx={{ minHeight: 48, justifyContent: open ? 'initial' : 'center', px: 2.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 0, mr: open ? 3 : 'auto', justifyContent: 'center' }}>{icon}</ListItemIcon>
|
||||
<ListItemText primary={label} sx={{ opacity: open ? 1 : 0 }} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Divider />
|
||||
<List>
|
||||
{roles?.includes('ADMIN') && (
|
||||
<ListItem disablePadding sx={{ display: 'block' }}>
|
||||
<ListItemButton
|
||||
onClick={() => setAdminOpen(!adminOpen)}
|
||||
sx={{ minHeight: 48, justifyContent: open ? 'initial' : 'center', px: 2.5 }}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 0, mr: open ? 3 : 'auto', justifyContent: 'center' }}>
|
||||
<AdminPanelSettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Admin" sx={{ opacity: open ? 1 : 0 }} />
|
||||
</ListItemButton>
|
||||
<Collapse in={adminOpen} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ pl: 4 }}>
|
||||
<ListItemButton component={Link} href="/admin/users">
|
||||
<ListItemText primary="User Management" />
|
||||
</ListItemButton>
|
||||
<ListItemButton component={Link} href="/admin/devices">
|
||||
<ListItemText primary="Device Management" />
|
||||
</ListItemButton>
|
||||
<ListItemButton component={Link} href="/admin/settings">
|
||||
<ListItemText primary="Settings" />
|
||||
</ListItemButton>
|
||||
<ListItemButton component={Link} href="/admin/statistics">
|
||||
<ListItemText primary="Statistics" />
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</ListItem>
|
||||
)}
|
||||
<ListItem disablePadding sx={{ display: 'block' }}>
|
||||
<ListItemButton onClick={toggle} sx={{ justifyContent: open ? 'initial' : 'center', px: 2.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 0, mr: open ? 3 : 'auto', justifyContent: 'center' }}>
|
||||
{darkMode ? <LightModeIcon /> : <DarkModeIcon />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={`${darkMode ? 'Light' : 'Dark'} Mode`} sx={{ opacity: open ? 1 : 0 }} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
</List>
|
||||
|
||||
|
||||
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: open ? 'space-between' : 'center', px: open ? 0 : 0, py: 0, bgcolor: 'action.hover', borderTop: `1px solid ${theme.palette.divider}`, minHeight: 50 }}>
|
||||
<UserMenu username={username} onLogout={handleLogout} open={open} displayname={displayname}/>
|
||||
</Box>
|
||||
|
||||
<ChangePasswordDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
{/* ⬇️ this is the actual page body */}
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
height: '100%',
|
||||
paddingTop: '64px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
64
src/components/SwitchTextTrack.tsx
Normal file
64
src/components/SwitchTextTrack.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { styled } from "@mui/material/styles";
|
||||
import Switch, { switchClasses } from "@mui/material/Switch";
|
||||
|
||||
export const SwitchTextTrack = styled(Switch)({
|
||||
width: 80,
|
||||
height: 48,
|
||||
padding: 8,
|
||||
[`& .${switchClasses.switchBase}`]: {
|
||||
padding: 11,
|
||||
color: "#ff6a00",
|
||||
},
|
||||
[`& .${switchClasses.thumb}`]: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
[`& .${switchClasses.track}`]: {
|
||||
background: "linear-gradient(to right, #ee0979, #ff6a00)",
|
||||
opacity: "1 !important",
|
||||
borderRadius: 20,
|
||||
position: "relative",
|
||||
"&:before, &:after": {
|
||||
display: "inline-block",
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
width: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "#fff",
|
||||
textAlign: "center",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
"&:before": {
|
||||
content: '"ON"',
|
||||
left: 4,
|
||||
opacity: 0,
|
||||
},
|
||||
"&:after": {
|
||||
content: '"OFF"',
|
||||
right: 4,
|
||||
},
|
||||
},
|
||||
[`& .${switchClasses.checked}`]: {
|
||||
[`&.${switchClasses.switchBase}`]: {
|
||||
color: "#185a9d",
|
||||
transform: "translateX(32px)",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(24,90,257,0.08)",
|
||||
},
|
||||
},
|
||||
[`& .${switchClasses.thumb}`]: {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
[`& + .${switchClasses.track}`]: {
|
||||
background: "linear-gradient(to right, #43cea2, #185a9d)",
|
||||
"&:before": {
|
||||
opacity: 1,
|
||||
},
|
||||
"&:after": {
|
||||
opacity: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
19
src/components/ThemeToggle.tsx
Normal file
19
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Tooltip, IconButton } from '@mui/material';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
import { useThemeMode } from '@/context/ThemeContext';
|
||||
|
||||
export default function ThemeToggle({ className = '' }: { className?: string }) {
|
||||
const { darkMode, toggle } = useThemeMode();
|
||||
|
||||
return (
|
||||
<Tooltip title={`Switch to ${darkMode ? 'light' : 'dark'} mode`}>
|
||||
<IconButton onClick={toggle} color="inherit" className={className}>
|
||||
{darkMode ? <LightModeIcon /> : <DarkModeIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
15
src/components/Twemoji.tsx
Normal file
15
src/components/Twemoji.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
// src/components/Twemoji.tsx
|
||||
import twemoji from 'twemoji';
|
||||
|
||||
export function parseEmojis(text: string): string {
|
||||
const withEmojis = twemoji.parse(text, {
|
||||
base: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/',
|
||||
folder: 'svg',
|
||||
ext: '.svg',
|
||||
className: 'emoji',
|
||||
});
|
||||
|
||||
// 🔁 Convert newlines to HTML <br>
|
||||
return withEmojis.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
79
src/components/UserMenu.tsx
Normal file
79
src/components/UserMenu.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Avatar, Typography, Tooltip, IconButton } from '@mui/material';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import Link from 'next/link';
|
||||
import stringAvatar from '@/app/utils/stringAvatar';
|
||||
import ChangePasswordDrawer from '@/components/ChangePasswordDrawer';
|
||||
|
||||
interface UserMenuProps {
|
||||
username: string;
|
||||
displayname: string;
|
||||
onLogout: () => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
const UserMenu: React.FC<UserMenuProps> = ({ username, displayname, onLogout, open }) => {
|
||||
|
||||
const theme = useTheme();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: open ? 'space-between' : 'center',
|
||||
px: open ? 1.5 : 0,
|
||||
py: 1.5,
|
||||
bgcolor: 'action.hover',
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
minHeight: 50,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
{...stringAvatar(username)}
|
||||
sx={{ width: 32, height: 32, cursor: 'pointer', textDecoration: 'none' }}
|
||||
component={Link}
|
||||
href="/user-profile"
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<Typography
|
||||
component={Link}
|
||||
href="/user-profile"
|
||||
variant="body2"
|
||||
noWrap
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
ml: 1,
|
||||
flexGrow: 1,
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{displayname}
|
||||
</Typography>
|
||||
<Tooltip title="Logout">
|
||||
<IconButton size="small" onClick={onLogout} sx={{ color: 'text.secondary' }}>
|
||||
<LogoutIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ChangePasswordDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMenu;
|
||||
396
src/components/admin/AdminControlsPanel.tsx
Normal file
396
src/components/admin/AdminControlsPanel.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
// src/components/admin/AdminControlsPanel.tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Snackbar,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from '@mui/material';
|
||||
import { SwitchTextTrack } from '@/components/SwitchTextTrack';
|
||||
import CVELogStream from '@/components/CVELogStream';
|
||||
import api from '@/lib/axios';
|
||||
|
||||
|
||||
export default function AdminControlsPanel() {
|
||||
const [pingEnabled, setPingEnabled] = useState<boolean | null>(null);
|
||||
const [message, setMessage] = useState<string>('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [logOutput, setLogOutput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toastOpen, setToastOpen] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const [clientList, setClientList] = useState<Array<{clientId: number;clientIdentifier: string;clientName: string;}>>([]);
|
||||
const [selectedClientId, setSelectedClientId] = useState<number | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [cveVisible, setCveVisible] = useState(false);
|
||||
const [kevVisible, setKevVisible] = useState(false);
|
||||
const [msrcVisible, setMsrcVisible] = useState(false);
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialState = async () => {
|
||||
try {
|
||||
const res = await api.get("/system/ping-status");
|
||||
if (res?.data?.acceptPings !== undefined) {
|
||||
setPingEnabled(res.data.acceptPings);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Failed to fetch ping status", err);
|
||||
setPingEnabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialState();
|
||||
}, []);
|
||||
|
||||
const togglePing = async (enabled: boolean) => {
|
||||
try {
|
||||
const res = await api.post(`/admin/toggle-ping?enabled=${enabled}`);
|
||||
setMessage(res.data);
|
||||
setPingEnabled(enabled);
|
||||
} catch (err) {
|
||||
console.error("Toggle failed:", err);
|
||||
setMessage("Failed to update ping status");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const res = await api.get('/admin/scripts/fetch-cve/logs');
|
||||
setLogOutput(res.data);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [drawerOpen]);
|
||||
|
||||
const fetchClients = async () => {
|
||||
try {
|
||||
const res = await api.get('/auth/clients'); // Adjust endpoint if needed
|
||||
setClientList(res.data); // Expected: [{ clientId, name }]
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch clients', err);
|
||||
}
|
||||
};
|
||||
|
||||
const runCveSync = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await api.post('/admin/scripts/fetch-cve');
|
||||
setCveVisible(true); // 👈 Spawn CVE console
|
||||
} catch (err: any) {
|
||||
alert("❌ Failed to start CVE sync: " + (err?.response?.data || err.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const runKevSync = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await api.post('/admin/scripts/fetch-kev');
|
||||
setKevVisible(true); // 👈 Spawn KEV console
|
||||
setToastMessage('✅ KEV sync started.');
|
||||
setToastOpen(true);
|
||||
} catch (err: any) {
|
||||
setToastMessage("❌ Failed to sync KEVs: " + (err?.response?.data || err.message));
|
||||
setToastOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runMsrcSync = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await api.post('/admin/scripts/fetch-msrc');
|
||||
setMsrcVisible(true); // 👈 Spawn MSRC console
|
||||
setToastMessage('✅ MSRC sync started.');
|
||||
setToastOpen(true);
|
||||
} catch (err: any) {
|
||||
setToastMessage("❌ Failed to fetch Microsoft CVEs: " + (err?.response?.data || err.message));
|
||||
setToastOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runVulnCacheRefresh = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.post('/admin/vulns/refresh-cache');
|
||||
setToastMessage(res.data); // Show backend result in toast
|
||||
setToastOpen(true);
|
||||
} catch (err: any) {
|
||||
setToastMessage("❌ Failed to refresh vulnerability cache: " + (err?.response?.data || err.message));
|
||||
setToastOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSoftwareCache = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.post('/admin/software/refresh-cache');
|
||||
setToastMessage(res.data);
|
||||
setToastOpen(true);
|
||||
} catch (err: any) {
|
||||
setToastMessage("❌ Failed to refresh installed software cache: " + (err?.response?.data || err.message));
|
||||
setToastOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshStatistics = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await api.post('/admin/statistics/refresh');
|
||||
setToastMessage('✅ CVE Statistics refreshed.');
|
||||
setToastOpen(true);
|
||||
} catch (err: any) {
|
||||
setToastMessage("❌ Failed to refresh statistics: " + (err?.response?.data || err.message));
|
||||
setToastOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeSoftware = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.post('/admin/software/normalize');
|
||||
setToastMessage(res.data); // ✅ show result
|
||||
setToastOpen(true);
|
||||
} catch (err: any) {
|
||||
setToastMessage("❌ Failed to normalize software entries: " + (err?.response?.data || err.message));
|
||||
setToastOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateDemoDevice = async () => {
|
||||
const clientId = prompt("Enter clientId for the demo device:");
|
||||
|
||||
if (!clientId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.post('/system/devices/demo', {
|
||||
clientId: Number(clientId)
|
||||
});
|
||||
setToastMessage(`✅ Demo device created: ${res.data.deviceId}`);
|
||||
} catch (err: any) {
|
||||
setToastMessage("❌ Failed to generate demo device: " + (err?.response?.data || err.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setToastOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const openDialog = async () => {
|
||||
await fetchClients();
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>Admin Controls</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Typography>Accept pings:</Typography>
|
||||
{pingEnabled === null ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<SwitchTextTrack
|
||||
checked={pingEnabled}
|
||||
disabled={pingEnabled === null}
|
||||
onChange={(e) => togglePing(e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{message && <Alert severity="info" sx={{ mb: 2 }}>{message}</Alert>}
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={runCveSync}
|
||||
disabled={loading}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{loading ? 'Running Sync...' : 'Run CVE Sync'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={runVulnCacheRefresh}
|
||||
disabled={loading}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{loading ? 'Refreshing Cache...' : 'Recheck Device Vulnerabilities'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={refreshSoftwareCache}
|
||||
disabled={loading}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{loading ? 'Refreshing Software Cache...' : 'Recheck Installed Software'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={normalizeSoftware}
|
||||
disabled={loading}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{loading ? 'Normalizing...' : 'Normalize Installed Software'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={runKevSync}
|
||||
disabled={loading}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{loading ? 'Syncing KEVs...' : 'Import CISA KEVs'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={runMsrcSync}
|
||||
disabled={loading}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{loading ? 'Syncing MSRC...' : 'Import Microsoft CVEs'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={refreshStatistics}
|
||||
disabled={loading}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{loading ? 'Refreshing Statistics...' : 'Refresh CVE Statistics'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={openDialog}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Add Demo Device
|
||||
</Button>
|
||||
|
||||
|
||||
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
<CVELogStream
|
||||
visible={cveVisible}
|
||||
title="🔍 CVE Sync Logs"
|
||||
streamUrl="/admin/scripts/fetch-cve/logs/stream"
|
||||
clearUrl="/admin/scripts/fetch-cve/clear-logs"
|
||||
/>
|
||||
|
||||
<CVELogStream
|
||||
visible={kevVisible}
|
||||
title="🛡️ KEV Sync Logs"
|
||||
streamUrl="/admin/scripts/fetch-kev/logs/stream"
|
||||
clearUrl="/admin/scripts/fetch-kev/clear-logs"
|
||||
/>
|
||||
|
||||
<CVELogStream
|
||||
visible={msrcVisible}
|
||||
title="🖥️ MSRC Sync Logs"
|
||||
streamUrl="/admin/scripts/fetch-msrc/logs/stream"
|
||||
clearUrl="/admin/scripts/fetch-msrc/clear-logs"
|
||||
/>
|
||||
|
||||
|
||||
<Snackbar
|
||||
open={toastOpen}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setToastOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => setToastOpen(false)}
|
||||
severity="info"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{toastMessage}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Select a Client</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel id="client-select-label">Client</InputLabel>
|
||||
<Select
|
||||
labelId="client-select-label"
|
||||
value={selectedClientId ?? ''}
|
||||
onChange={(e) => setSelectedClientId(Number(e.target.value))}
|
||||
label="Client"
|
||||
>
|
||||
{clientList.map((client) => (
|
||||
<MenuItem key={client.clientId} value={client.clientId}>
|
||||
{client.clientName} ({client.clientIdentifier})
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!selectedClientId || loading}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.post('/system/devices/demo', {
|
||||
clientId: selectedClientId,
|
||||
});
|
||||
setToastMessage(`✅ Demo device created: ${res.data.deviceId}`);
|
||||
setToastOpen(true);
|
||||
setDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
setToastMessage("❌ Failed to create demo device: " + (err?.response?.data || err.message));
|
||||
setToastOpen(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
271
src/components/admin/DeviceTableSection.tsx
Normal file
271
src/components/admin/DeviceTableSection.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
// src/components/admin/DeviceTableSection.tsx
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, IconButton, Typography, Box, Tooltip, Button, Snackbar, Alert,Select, MenuItem,
|
||||
TableSortLabel, TextField,
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import api from '@/lib/axios';
|
||||
import TablePagination from '@mui/material/TablePagination';
|
||||
|
||||
|
||||
interface DeviceDTO {
|
||||
deviceId: number;
|
||||
hostname: string;
|
||||
lastCheckedIn: string;
|
||||
clientId: number;
|
||||
clientIdentifier: string;
|
||||
clientName: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface Props {
|
||||
initialDevices: DeviceDTO[];
|
||||
}
|
||||
|
||||
export default function DeviceTableSection({ initialDevices }: Props) {
|
||||
const [devices, setDevices] = useState<DeviceDTO[]>(initialDevices);
|
||||
const [clients, setClients] = useState<Array<{ clientId: number, clientIdentifier: string, clientName: string }>>([]);
|
||||
const [toastMessage, setToastMessage] = useState<string>('');
|
||||
const [toastOpen, setToastOpen] = useState(false);
|
||||
const [editingDeviceId, setEditingDeviceId] = useState<number | null>(null);
|
||||
const [editedClientId, setEditedClientId] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [orderBy, setOrderBy] = useState<keyof DeviceDTO>('hostname');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
console.log("🖥️ initialDevices:", initialDevices);
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchClients = async () => {
|
||||
try {
|
||||
const res = await api.get('/auth/clients');
|
||||
console.log("📦 /auth/clients response:", res.data); // ✅ Inspect what you received
|
||||
setClients(res.data);
|
||||
} catch (err) {
|
||||
console.error("❌ Failed to fetch clients:", err);
|
||||
}
|
||||
};
|
||||
fetchClients();
|
||||
}, []);
|
||||
const handleSort = (field: keyof DeviceDTO) => {
|
||||
const isAsc = orderBy === field && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(field);
|
||||
};
|
||||
|
||||
|
||||
const filteredDevices = devices
|
||||
.filter(d =>
|
||||
d.hostname.toLowerCase().includes(search.toLowerCase()) ||
|
||||
d.clientName?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const valA = a[orderBy] ?? '';
|
||||
const valB = b[orderBy] ?? '';
|
||||
if (valA < valB) return order === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return order === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
|
||||
const formatLastCheckedIn = (isoString: string): string => {
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
|
||||
const formatted = date.toLocaleString('en-AU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
return `${formatted} (${diffHours} hours ago)`;
|
||||
};
|
||||
|
||||
|
||||
const handleClientUpdate = async (deviceId: number, newClientId: number) => {
|
||||
try {
|
||||
await api.put(`/admin/manage/devices/${deviceId}/client`, { clientId: newClientId });
|
||||
setDevices(prev =>
|
||||
prev.map(d =>
|
||||
d.deviceId === deviceId
|
||||
? {
|
||||
...d,
|
||||
clientId: newClientId,
|
||||
clientName: clients.find(c => c.clientId === newClientId)?.clientName || ''
|
||||
}
|
||||
: d
|
||||
)
|
||||
);
|
||||
setToastMessage('✅ Client updated');
|
||||
} catch (err) {
|
||||
setToastMessage('❌ Failed to update client');
|
||||
} finally {
|
||||
setToastOpen(true);
|
||||
setEditingDeviceId(null);
|
||||
setEditedClientId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDelete = async (deviceId: number) => {
|
||||
const confirmDelete = confirm('Are you sure you want to delete this device?');
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/admin/manage/devices/${deviceId}`);
|
||||
setDevices((prev) => prev.filter((d) => d.deviceId !== deviceId));
|
||||
setToastMessage('✅ Device deleted');
|
||||
} catch (err) {
|
||||
setToastMessage('❌ Failed to delete device');
|
||||
} finally {
|
||||
setToastOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>Device Management</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search hostname or client..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 300 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sortDirection={orderBy === 'hostname' ? order : false}>
|
||||
<TableSortLabel
|
||||
active={orderBy === 'hostname'}
|
||||
direction={orderBy === 'hostname' ? order : 'asc'}
|
||||
onClick={() => handleSort('hostname')}
|
||||
>
|
||||
Hostname
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
|
||||
<TableCell sortDirection={orderBy === 'clientName' ? order : false}>
|
||||
<TableSortLabel
|
||||
active={orderBy === 'clientName'}
|
||||
direction={orderBy === 'clientName' ? order : 'asc'}
|
||||
onClick={() => handleSort('clientName')}
|
||||
>
|
||||
Client
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
|
||||
<TableCell sortDirection={orderBy === 'lastCheckedIn' ? order : false}>
|
||||
<TableSortLabel
|
||||
active={orderBy === 'lastCheckedIn'}
|
||||
direction={orderBy === 'lastCheckedIn' ? order : 'asc'}
|
||||
onClick={() => handleSort('lastCheckedIn')}
|
||||
>
|
||||
Last Checked In
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>Actions</TableCell>
|
||||
<TableCell>Edit</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{filteredDevices.map((device) => (
|
||||
<TableRow key={device.deviceId}>
|
||||
<TableCell>{device.hostname}</TableCell>
|
||||
<TableCell sx={{ minWidth: 220 }}>
|
||||
{editingDeviceId === device.deviceId ? (
|
||||
<Select
|
||||
value={editedClientId ?? device.clientId}
|
||||
onChange={(e) => setEditedClientId(Number(e.target.value))}
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
{clients.map((client) => (
|
||||
<MenuItem key={client.clientId} value={client.clientId}>
|
||||
{client.clientName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Box sx={{ minWidth: 220, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{device.clientName || '❓ Unknown client'}
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{formatLastCheckedIn(device.lastCheckedIn)}</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title="Delete Device">
|
||||
<IconButton color="error" onClick={() => handleDelete(device.deviceId)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell sx={{ width: 120 }}>
|
||||
{editingDeviceId === device.deviceId ? (
|
||||
<>
|
||||
<Tooltip title="Save">
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => handleClientUpdate(device.deviceId, editedClientId ?? device.clientId)}
|
||||
>
|
||||
<SaveIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Cancel">
|
||||
<IconButton onClick={() => setEditingDeviceId(null)}>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip title="Edit Client">
|
||||
<IconButton onClick={() => {
|
||||
setEditingDeviceId(device.deviceId);
|
||||
setEditedClientId(device.clientId);
|
||||
}}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Snackbar
|
||||
open={toastOpen}
|
||||
autoHideDuration={5000}
|
||||
onClose={() => setToastOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="info" sx={{ width: '100%' }}>{toastMessage}</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
36
src/components/admin/UserEnableToggle.tsx
Normal file
36
src/components/admin/UserEnableToggle.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Switch } from '@mui/material';
|
||||
import api from '@/lib/axios';
|
||||
|
||||
interface UserEnableToggleProps {
|
||||
userId: number;
|
||||
initialEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function UserEnableToggle({ userId, initialEnabled }: UserEnableToggleProps) {
|
||||
const [enabled, setEnabled] = useState(initialEnabled);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleToggle = async () => {
|
||||
const newState = !enabled;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/enabled?enabled=${newState}`);
|
||||
setEnabled(newState);
|
||||
} catch (err) {
|
||||
console.error('Failed to update user enabled state:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
115
src/components/admin/UserTableSection.tsx
Normal file
115
src/components/admin/UserTableSection.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
// src/components/admin/UserTableSection.tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Typography, Table, TableBody, TableCell, TableContainer, TableHead,
|
||||
TableRow, Paper, Box, Button, Dialog, DialogTitle, DialogContent, DialogActions
|
||||
} from '@mui/material';
|
||||
import UserEnableToggle from './UserEnableToggle';
|
||||
import AddUserForm from './forms/AddUserForm';
|
||||
import AddClientForm from './forms/AddClientForm';
|
||||
import InviteUserForm from './forms/InviteUserForm';
|
||||
|
||||
interface UserDTO {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
clientName: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export default function UserTableSection({ initialUsers }: { initialUsers: UserDTO[] }) {
|
||||
const [users, setUsers] = useState<UserDTO[]>(initialUsers);
|
||||
const [openUserDialog, setOpenUserDialog] = useState(false);
|
||||
const [openClientDialog, setOpenClientDialog] = useState(false);
|
||||
const [openInviteUserDialog, setOpenInviteUserDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>User Management</Typography>
|
||||
|
||||
<Box sx={{ mb: 2, display: 'flex', gap: 2 }}>
|
||||
<Button variant="contained" color="primary" onClick={() => setOpenClientDialog(true)}>
|
||||
Add Client
|
||||
</Button>
|
||||
<Button variant="contained" color="success" onClick={() => setOpenUserDialog(true)}>
|
||||
Add User
|
||||
</Button>
|
||||
<Button variant="contained" color="success" onClick={() => setOpenInviteUserDialog(true)}>
|
||||
Invite User
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Display Name</TableCell>
|
||||
<TableCell>First Name</TableCell>
|
||||
<TableCell>Last Name</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.displayName}</TableCell>
|
||||
<TableCell>{user.firstName}</TableCell>
|
||||
<TableCell>{user.lastName}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.role}</TableCell>
|
||||
<TableCell>{user.clientName}</TableCell>
|
||||
<TableCell>
|
||||
<UserEnableToggle userId={user.id} initialEnabled={user.enabled} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Add Client Dialog */}
|
||||
<Dialog open={openClientDialog} onClose={() => setOpenClientDialog(false)}>
|
||||
<DialogTitle>Register New Client</DialogTitle>
|
||||
<DialogContent>
|
||||
<AddClientForm onClose={() => setOpenClientDialog(false)} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpenClientDialog(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Add User Dialog */}
|
||||
<Dialog open={openUserDialog} onClose={() => setOpenUserDialog(false)}>
|
||||
<DialogTitle>Register New User</DialogTitle>
|
||||
<DialogContent>
|
||||
<AddUserForm onClose={() => setOpenUserDialog(false)} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpenUserDialog(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Invite User Dialog */}
|
||||
<Dialog open={openInviteUserDialog} onClose={() => setOpenInviteUserDialog(false)}>
|
||||
<DialogTitle>Invite New User</DialogTitle>
|
||||
<DialogContent>
|
||||
<InviteUserForm onClose={() => setOpenInviteUserDialog(false)} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpenInviteUserDialog(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
50
src/components/admin/forms/AddClientForm.tsx
Normal file
50
src/components/admin/forms/AddClientForm.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// src/components/admin/AddClientForm.tsx
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Button, TextField, DialogContent, DialogActions, Alert } from '@mui/material';
|
||||
import api from '@/lib/axios';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onClientCreated?: () => void;
|
||||
}
|
||||
|
||||
export default function AddClientForm({ onClose, onClientCreated }: Props) {
|
||||
const [clientName, setClientName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const res = await api.post('/auth/register/client', { clientName });
|
||||
setSuccess(`Client ID: ${res.data.clientId}, Identifier: ${res.data.clientIdentifier}`);
|
||||
setClientName('');
|
||||
setError('');
|
||||
if (onClientCreated) onClientCreated();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Unexpected error');
|
||||
setSuccess('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="dense"
|
||||
label="Client Name"
|
||||
value={clientName}
|
||||
onChange={(e) => setClientName(e.target.value)}
|
||||
/>
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mt: 2 }}>{success}</Alert>}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSubmit}>Register</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
120
src/components/admin/forms/AddUserForm.tsx
Normal file
120
src/components/admin/forms/AddUserForm.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
// src/components/admin/AddUserForm.tsx
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, TextField, DialogContent, DialogActions, Alert, InputLabel,
|
||||
MenuItem, FormControl, Select
|
||||
} from '@mui/material';
|
||||
import api from '@/lib/axios';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onUserCreated?: () => void;
|
||||
}
|
||||
|
||||
export default function AddUserForm({ onClose, onUserCreated }: Props) {
|
||||
const [clients, setClients] = useState<Array<{ clientId: number; clientIdentifier: string; clientName: string }>>([]);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [role, setRole] = useState('USER');
|
||||
const [selectedClientId, setSelectedClientId] = useState('');
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchClients = async () => {
|
||||
try {
|
||||
const res = await api.get('/auth/clients');
|
||||
setClients(res.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to load clients');
|
||||
}
|
||||
};
|
||||
fetchClients();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.post('/auth/register/user', {
|
||||
username,
|
||||
password,
|
||||
role,
|
||||
clientId: Number(selectedClientId)
|
||||
});
|
||||
setSuccess(`User registered with ID: ${res.data.userId}`);
|
||||
setError('');
|
||||
if (onUserCreated) onUserCreated();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Unexpected error');
|
||||
setSuccess('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="dense"
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="dense"
|
||||
type="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="dense"
|
||||
type="password"
|
||||
label="Confirm Password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel id="role-label">Role</InputLabel>
|
||||
<Select
|
||||
labelId="role-label"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
>
|
||||
<MenuItem value="USER">USER</MenuItem>
|
||||
<MenuItem value="ADMIN">ADMIN</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel id="client-label">Client</InputLabel>
|
||||
<Select
|
||||
labelId="client-label"
|
||||
value={selectedClientId}
|
||||
onChange={(e) => setSelectedClientId(e.target.value)}
|
||||
>
|
||||
{clients.map(client => (
|
||||
<MenuItem key={client.clientId} value={client.clientId.toString()}>
|
||||
{client.clientName} ({client.clientIdentifier})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mt: 2 }}>{success}</Alert>}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSubmit}>Register</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
113
src/components/admin/forms/InviteUserForm.tsx
Normal file
113
src/components/admin/forms/InviteUserForm.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// src/components/admin/InviteUserForm.tsx
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, TextField, DialogContent, DialogActions, Alert, InputLabel,
|
||||
MenuItem, FormControl, Select, CircularProgress
|
||||
} from '@mui/material';
|
||||
import api from '@/lib/axios';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onUserInvited?: () => void;
|
||||
}
|
||||
|
||||
export default function InviteUserForm({ onClose, onUserInvited }: Props) {
|
||||
const [clients, setClients] = useState<Array<{ clientId: number; clientIdentifier: string; clientName: string }>>([]);
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState('USER');
|
||||
const [selectedClientId, setSelectedClientId] = useState('');
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false); // <-- NEW
|
||||
|
||||
useEffect(() => {
|
||||
const fetchClients = async () => {
|
||||
try {
|
||||
const res = await api.get('/auth/clients');
|
||||
setClients(res.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to load clients');
|
||||
}
|
||||
};
|
||||
fetchClients();
|
||||
}, []);
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!email) {
|
||||
setError("Email is required");
|
||||
return;
|
||||
}
|
||||
setLoading(true); // <-- NEW
|
||||
try {
|
||||
const res = await api.post('/auth/invite', {
|
||||
email,
|
||||
role,
|
||||
clientId: Number(selectedClientId)
|
||||
});
|
||||
setSuccess(`Invitation sent to: ${email}`);
|
||||
setError('');
|
||||
if (onUserInvited) onUserInvited();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Unexpected error');
|
||||
setSuccess('');
|
||||
} finally {
|
||||
setLoading(false); // <-- NEW
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
margin="dense"
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel id="role-label">Role</InputLabel>
|
||||
<Select
|
||||
labelId="role-label"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
>
|
||||
<MenuItem value="USER">USER</MenuItem>
|
||||
<MenuItem value="ADMIN">ADMIN</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel id="client-label">Client</InputLabel>
|
||||
<Select
|
||||
labelId="client-label"
|
||||
value={selectedClientId}
|
||||
onChange={(e) => setSelectedClientId(e.target.value)}
|
||||
>
|
||||
{clients.map(client => (
|
||||
<MenuItem key={client.clientId} value={client.clientId.toString()}>
|
||||
{client.clientName} ({client.clientIdentifier})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mt: 2 }}>{success}</Alert>}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={loading}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleInvite}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : null}
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Invite'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
403
src/components/client/DevicesClient.tsx
Normal file
403
src/components/client/DevicesClient.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import DeviceListSidebar from '@/components/DeviceListSidebar';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
TextField,
|
||||
Table,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import { format, formatDistanceToNowStrict, parseISO } from 'date-fns';
|
||||
import { useDeviceContext } from '@/context/DeviceContext';
|
||||
import VulnerabilityTableWithControls from '../vuln/VulnerabilityTableWithControls';
|
||||
import Count_Critical from '@/components/gauges/Count_Critical';
|
||||
import Count_High from '@/components/gauges/Count_High';
|
||||
import Count_Medium from '@/components/gauges/Count_Medium';
|
||||
import Count_Low from '@/components/gauges/Count_Low';
|
||||
|
||||
interface DriveInfo {
|
||||
name: string;
|
||||
driveType: string;
|
||||
totalSizeGB: number;
|
||||
freeSpaceGB: number;
|
||||
}
|
||||
|
||||
interface IpAddress {
|
||||
interfaceName: string;
|
||||
ipAddress: string;
|
||||
macAddress?: string;
|
||||
}
|
||||
|
||||
interface DeviceVulnerability {
|
||||
cveId: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
score?: number;
|
||||
publishedDate: string;
|
||||
}
|
||||
|
||||
interface InstalledApp {
|
||||
app_name: string;
|
||||
app_version: string;
|
||||
publisher: string;
|
||||
}
|
||||
|
||||
interface DetailedDevice {
|
||||
deviceId: number;
|
||||
hostname: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
windowsVersion: string;
|
||||
windowsBuild: string;
|
||||
osArchitecture: string;
|
||||
processorName: string;
|
||||
processorArchitecture: string;
|
||||
gpuNames: string[];
|
||||
totalMemory: string;
|
||||
lastBootTime: string;
|
||||
lastCheckedIn: string;
|
||||
drives: DriveInfo[];
|
||||
ipAddresses: IpAddress[];
|
||||
installedApplications: InstalledApp[];
|
||||
clientName?: string;
|
||||
}
|
||||
|
||||
const isCriticalSeverity = (severity: string) => {
|
||||
const sev = severity.toLowerCase();
|
||||
return sev === 'critical' || sev === 'unknown';
|
||||
};
|
||||
|
||||
|
||||
export default function DevicesClient() {
|
||||
const [selectedDevice, setSelectedDevice] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { devices, deviceVulns } = useDeviceContext();
|
||||
console.log('👀 Devices from context inside DevicesClient:', devices);
|
||||
|
||||
// Flatten vulnerabilities for the selected device
|
||||
const vulnerabilities: DeviceVulnerability[] = selectedDevice !== null
|
||||
? deviceVulns[selectedDevice] ?? []
|
||||
: [];
|
||||
|
||||
// Vuln Counts (how many CVEs of each type)
|
||||
const criticalVulnCount = vulnerabilities.filter(v => isCriticalSeverity(v.severity)).length;
|
||||
const highVulnCount = vulnerabilities.filter(v => v.severity.toLowerCase() === 'high').length;
|
||||
const mediumVulnCount = vulnerabilities.filter(v => v.severity.toLowerCase() === 'medium').length;
|
||||
const lowVulnCount = vulnerabilities.filter(v => v.severity.toLowerCase() === 'low').length;
|
||||
|
||||
// Device Affected (since we are viewing per device, it’s always 0 or 1)
|
||||
const criticalDeviceCount = vulnerabilities.some(v => isCriticalSeverity(v.severity)) ? 1 : 0;
|
||||
const highDeviceCount = highVulnCount > 0 ? 1 : 0;
|
||||
const mediumDeviceCount = mediumVulnCount > 0 ? 1 : 0;
|
||||
const lowDeviceCount = lowVulnCount > 0 ? 1 : 0;
|
||||
|
||||
// Also fix totalDevices
|
||||
const totalDevices = devices.length;
|
||||
|
||||
|
||||
|
||||
const [deviceSidebarOpen, setDeviceSidebarOpen] = useState(false);
|
||||
const [deviceSidebarPinned, setDeviceSidebarPinned] = useState(false);
|
||||
const [deviceSidebarHoverTimer, setDeviceSidebarHoverTimer] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const pinned = localStorage.getItem('deviceSidebarPinned') === 'true';
|
||||
setDeviceSidebarPinned(pinned);
|
||||
setDeviceSidebarOpen(pinned);
|
||||
}, []);
|
||||
|
||||
const toggleDeviceSidebarPin = () => {
|
||||
const next = !deviceSidebarPinned;
|
||||
setDeviceSidebarPinned(next);
|
||||
setDeviceSidebarOpen(next);
|
||||
localStorage.setItem('deviceSidebarPinned', String(next));
|
||||
};
|
||||
|
||||
const selectedDetails = devices.find((d) => d.deviceId === selectedDevice) as DetailedDevice | undefined;
|
||||
|
||||
const formatBootTime = (timestamp: string) => {
|
||||
try {
|
||||
const parsed = parseISO(timestamp);
|
||||
const formattedDate = format(parsed, 'EEEE do MMMM yyyy');
|
||||
const relative = formatDistanceToNowStrict(parsed, { addSuffix: true });
|
||||
return `${formattedDate} (${relative})`;
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
console.log('🧩 selectedDetails:', selectedDetails);
|
||||
if (selectedDetails) {
|
||||
console.log('🔧 Hostname:', selectedDetails.hostname);
|
||||
console.log('👤 Client Name:', selectedDetails.clientName);
|
||||
}
|
||||
}, [selectedDetails]);
|
||||
|
||||
|
||||
return (
|
||||
<Box component="main" sx={{ display: 'flex', flexGrow: 1, minHeight: 0, height: '100%' }}>
|
||||
<DeviceListSidebar
|
||||
devices={devices}
|
||||
selectedDevice={selectedDevice}
|
||||
onSelect={setSelectedDevice}
|
||||
open={deviceSidebarOpen}
|
||||
pinned={deviceSidebarPinned}
|
||||
onTogglePin={toggleDeviceSidebarPin}
|
||||
onMouseEnter={() => {
|
||||
if (!deviceSidebarPinned) {
|
||||
const timer = setTimeout(() => setDeviceSidebarOpen(true), 200);
|
||||
setDeviceSidebarHoverTimer(timer);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!deviceSidebarPinned) {
|
||||
if (deviceSidebarHoverTimer) clearTimeout(deviceSidebarHoverTimer);
|
||||
const timer = setTimeout(() => setDeviceSidebarOpen(false), 200);
|
||||
setDeviceSidebarHoverTimer(timer);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflowY: 'auto', minHeight: 0, pt: 0, pr: 0, pb: 2, pl: 0, }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap', // allow wrapping if needed
|
||||
gap: 2, // spacing between items
|
||||
px: 2,
|
||||
pt: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
minWidth: 200,
|
||||
}}
|
||||
>
|
||||
Device Overview{selectedDetails?.hostname ? ` - ${selectedDetails.hostname}` : ''}
|
||||
</Typography>
|
||||
|
||||
{selectedDetails?.clientName && (
|
||||
<Card variant="outlined" sx={{ flexShrink: 0, minWidth: 180, maxWidth: '100%' }}>
|
||||
<CardContent sx={{ py: 1, px: 2 }}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
{selectedDetails.clientName}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Either Device Info or Prompt */}
|
||||
<Grid size={{ xs: 12}}>
|
||||
{selectedDetails ? (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Grid container spacing={0}>
|
||||
{/* Row 1: System Info */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 1.5 }}>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow><TableCell>OS</TableCell><TableCell>{selectedDetails.osName} {selectedDetails.osVersion} ({selectedDetails.osArchitecture})</TableCell></TableRow>
|
||||
<TableRow><TableCell>Build</TableCell><TableCell>{selectedDetails.windowsVersion} / {selectedDetails.windowsBuild}</TableCell></TableRow>
|
||||
<TableRow><TableCell>Processor</TableCell><TableCell>{selectedDetails.processorName} ({selectedDetails.processorArchitecture})</TableCell></TableRow>
|
||||
<TableRow>
|
||||
<TableCell>GPUs</TableCell>
|
||||
<TableCell>
|
||||
{selectedDetails.gpuNames?.length > 0 ? (
|
||||
<ul style={{ margin: 0 }}>
|
||||
{selectedDetails.gpuNames.map((gpu, index) => (
|
||||
<li key={index}>{gpu}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
'N/A'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow><TableCell>Memory</TableCell><TableCell>{selectedDetails.totalMemory} ({(parseInt(selectedDetails.totalMemory) / 1024).toFixed(2)} GB)</TableCell></TableRow>
|
||||
<TableRow><TableCell>Last Boot</TableCell><TableCell>{formatBootTime(selectedDetails.lastBootTime)}</TableCell></TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Row 1: Drives */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 1.5 }}>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Drive</TableCell>
|
||||
<TableCell>Total (GB)</TableCell>
|
||||
<TableCell>Free (GB)</TableCell>
|
||||
<TableCell>% Free</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{selectedDetails?.drives?.map((drive, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{drive.name}</TableCell>
|
||||
<TableCell>{drive.totalSizeGB}</TableCell>
|
||||
<TableCell>{drive.freeSpaceGB}</TableCell>
|
||||
<TableCell>{((drive.freeSpaceGB / drive.totalSizeGB) * 100).toFixed(1)}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 1.5 }}>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Interface</TableCell>
|
||||
<TableCell>IP Address</TableCell>
|
||||
<TableCell>MAC Address</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{selectedDetails?.ipAddresses?.map((ip, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{ip.interfaceName}</TableCell>
|
||||
<TableCell>{ip.ipAddress}</TableCell>
|
||||
<TableCell>{ip.macAddress ?? 'N/A'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
|
||||
{/* Row 1: Known Vulnerabilities */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<Count_Critical
|
||||
criticalCount={criticalVulnCount}
|
||||
totalDevices={totalDevices}
|
||||
affectedDevices={criticalDeviceCount}
|
||||
compact
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<Count_High
|
||||
highCount={highVulnCount}
|
||||
totalDevices={totalDevices}
|
||||
affectedDevices={highDeviceCount}
|
||||
compact
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<Count_Medium
|
||||
mediumCount={mediumVulnCount}
|
||||
totalDevices={totalDevices}
|
||||
affectedDevices={mediumDeviceCount}
|
||||
compact
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<Count_Low
|
||||
lowCount={lowVulnCount}
|
||||
totalDevices={totalDevices}
|
||||
affectedDevices={lowDeviceCount}
|
||||
compact
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
|
||||
|
||||
|
||||
<Grid size={12}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<VulnerabilityTableWithControls vulns={vulnerabilities} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
|
||||
{/* Row 2: Installed Applications */}
|
||||
<Grid size={12}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h5" gutterBottom>Installed Applications</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Search Applications"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
onChange={(e) => setSearchQuery(e.target.value.toLowerCase())}
|
||||
/>
|
||||
<TableContainer component={Paper} sx={{ maxHeight: 300 }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Publisher</TableCell>
|
||||
<TableCell>Version</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{selectedDetails?.installedApplications
|
||||
?.filter(app => app.app_name.toLowerCase().includes(searchQuery))
|
||||
.map((app, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{app.app_name}</TableCell>
|
||||
<TableCell>{app.publisher}</TableCell>
|
||||
<TableCell>{app.app_version}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography padding={1.5}>Select a device from the list to view details.</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
76
src/components/gauges/Count_Critical.tsx
Normal file
76
src/components/gauges/Count_Critical.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box } from '@mui/material';
|
||||
|
||||
interface CountCriticalProps {
|
||||
criticalCount: number;
|
||||
totalDevices: number;
|
||||
affectedDevices: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const Count_Critical: React.FC<CountCriticalProps> = ({ criticalCount, totalDevices, affectedDevices, compact = false }) => {
|
||||
const percent = totalDevices > 0 ? Math.round((affectedDevices / totalDevices) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
minWidth: compact ? 140 : 275,
|
||||
minHeight: compact ? 140 : 220,
|
||||
bgcolor: 'background.paper',
|
||||
borderLeft: '6px solid',
|
||||
borderColor: 'error.main',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
display: 'flex', // 👈 make card full flex
|
||||
flexDirection: 'column', // 👈 vertical
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Line 1 */}
|
||||
<Typography variant={compact ? 'body1' : 'h6'}>
|
||||
Critical
|
||||
</Typography>
|
||||
|
||||
{/* Line 2 */}
|
||||
<Typography variant={compact ? 'body1' : 'h6'}>
|
||||
Vulnerabilities
|
||||
</Typography>
|
||||
|
||||
{/* Space before the number */}
|
||||
<Box mt={compact ? 1 : 2} />
|
||||
|
||||
{/* Number */}
|
||||
<Typography
|
||||
variant={compact ? 'h4' : 'h2'}
|
||||
sx={{ color: 'error.main', fontWeight: 700 }}
|
||||
>
|
||||
{criticalCount}
|
||||
</Typography>
|
||||
|
||||
{/* Subtext (only if not compact) */}
|
||||
{!compact && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Found on {affectedDevices} of {totalDevices} devices
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||
({percent}% affected)
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Count_Critical;
|
||||
70
src/components/gauges/Count_High.tsx
Normal file
70
src/components/gauges/Count_High.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box } from '@mui/material';
|
||||
|
||||
interface CountHighProps {
|
||||
highCount: number;
|
||||
totalDevices: number;
|
||||
affectedDevices: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const Count_High: React.FC<CountHighProps> = ({ highCount, totalDevices, affectedDevices, compact = false }) => {
|
||||
const percent = totalDevices > 0 ? Math.round((affectedDevices / totalDevices) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
minWidth: compact ? 140 : 275,
|
||||
minHeight: compact ? 140 : 220,
|
||||
bgcolor: 'background.paper',
|
||||
borderLeft: '6px solid',
|
||||
borderColor: 'warning.main',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant={compact ? 'body1' : 'h6'}>
|
||||
High
|
||||
</Typography>
|
||||
|
||||
<Typography variant={compact ? 'body1' : 'h6'}>
|
||||
Vulnerabilities
|
||||
</Typography>
|
||||
|
||||
<Box mt={compact ? 1 : 2} />
|
||||
|
||||
<Typography
|
||||
variant={compact ? 'h4' : 'h2'}
|
||||
sx={{ color: 'warning.main', fontWeight: 700 }}
|
||||
>
|
||||
{highCount}
|
||||
</Typography>
|
||||
|
||||
{!compact && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Found on {affectedDevices} of {totalDevices} devices
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||
({percent}% affected)
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Count_High;
|
||||
70
src/components/gauges/Count_Low.tsx
Normal file
70
src/components/gauges/Count_Low.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box } from '@mui/material';
|
||||
|
||||
interface CountLowProps {
|
||||
lowCount: number;
|
||||
totalDevices: number;
|
||||
affectedDevices: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const Count_Low: React.FC<CountLowProps> = ({ lowCount, totalDevices, affectedDevices, compact = false }) => {
|
||||
const percent = totalDevices > 0 ? Math.round((affectedDevices / totalDevices) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
minWidth: compact ? 140 : 275,
|
||||
minHeight: compact ? 140 : 220,
|
||||
bgcolor: 'background.paper',
|
||||
borderLeft: '6px solid',
|
||||
borderColor: 'info.main',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant={compact ? 'body1' : 'h6'}>
|
||||
Low
|
||||
</Typography>
|
||||
|
||||
<Typography variant={compact ? 'body1' : 'h6'}>
|
||||
Vulnerabilities
|
||||
</Typography>
|
||||
|
||||
<Box mt={compact ? 1 : 2} />
|
||||
|
||||
<Typography
|
||||
variant={compact ? 'h4' : 'h2'}
|
||||
sx={{ color: 'info.main', fontWeight: 700 }}
|
||||
>
|
||||
{lowCount}
|
||||
</Typography>
|
||||
|
||||
{!compact && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Found on {affectedDevices} of {totalDevices} devices
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||
({percent}% affected)
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Count_Low;
|
||||
70
src/components/gauges/Count_Medium.tsx
Normal file
70
src/components/gauges/Count_Medium.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box } from '@mui/material';
|
||||
|
||||
interface CountMediumProps {
|
||||
mediumCount: number;
|
||||
totalDevices: number;
|
||||
affectedDevices: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const Count_Medium: React.FC<CountMediumProps> = ({ mediumCount, totalDevices, affectedDevices, compact = false }) => {
|
||||
const percent = totalDevices > 0 ? Math.round((affectedDevices / totalDevices) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
minWidth: compact ? 140 : 275,
|
||||
minHeight: compact ? 140 : 220,
|
||||
bgcolor: 'background.paper',
|
||||
borderLeft: '6px solid',
|
||||
borderColor: 'success.main',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant={compact ? 'body1' : 'h6'}>
|
||||
Medium
|
||||
</Typography>
|
||||
|
||||
<Typography variant={compact ? 'body1' : 'h6'}>
|
||||
Vulnerabilities
|
||||
</Typography>
|
||||
|
||||
<Box mt={compact ? 1 : 2} />
|
||||
|
||||
<Typography
|
||||
variant={compact ? 'h4' : 'h2'}
|
||||
sx={{ color: 'success.main', fontWeight: 700 }}
|
||||
>
|
||||
{mediumCount}
|
||||
</Typography>
|
||||
|
||||
{!compact && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Found on {affectedDevices} of {totalDevices} devices
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||
({percent}% affected)
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Count_Medium;
|
||||
90
src/components/providers/AppThemeProvider.tsx
Normal file
90
src/components/providers/AppThemeProvider.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
// src/components/AppThemeProvider.tsx
|
||||
'use client';
|
||||
|
||||
import { createTheme, CssBaseline, ThemeProvider as MuiThemeProvider } from '@mui/material';
|
||||
import ConditionalLayout from '@/components/providers/ConditionalLayoutProvider';
|
||||
import { useThemeMode, ThemeProviderCustom } from '@/context/ThemeContext';
|
||||
import { brandColors } from '@/app/styles/colors';
|
||||
|
||||
export default function AppThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProviderCustom>
|
||||
<MUIWrappedLayout>{children}</MUIWrappedLayout>
|
||||
</ThemeProviderCustom>
|
||||
);
|
||||
}
|
||||
|
||||
function MUIWrappedLayout({ children }: { children: React.ReactNode }) {
|
||||
const { darkMode } = useThemeMode();
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: darkMode ? 'dark' : 'light',
|
||||
primary: {
|
||||
main: brandColors.primary,
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
secondary: {
|
||||
main: brandColors.secondary,
|
||||
},
|
||||
background: {
|
||||
default: darkMode ? brandColors.backgroundDark : brandColors.backgroundLight,
|
||||
paper: darkMode ? brandColors.paperDark : brandColors.paperLight,
|
||||
},
|
||||
text: {
|
||||
primary: darkMode ? brandColors.textDark : brandColors.textLight,
|
||||
},
|
||||
},
|
||||
|
||||
typography: {
|
||||
fontFamily: `'Inter', 'system-ui', sans-serif`,
|
||||
fontSize: 14,
|
||||
},
|
||||
components: {
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: darkMode ? brandColors.drawerDark : brandColors.paperLight,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiListItemButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: darkMode ? brandColors.selectedDark : brandColors.selectedLight,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardContent: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 12,
|
||||
userSelect: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
userSelect: 'none',
|
||||
outline: 'none',
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<ConditionalLayout>{children}</ConditionalLayout>
|
||||
</MuiThemeProvider>
|
||||
);
|
||||
}
|
||||
14
src/components/providers/ConditionalLayoutProvider.tsx
Normal file
14
src/components/providers/ConditionalLayoutProvider.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const SidebarLayout = dynamic(() => import('../SidebarLayout'), { ssr: false });
|
||||
|
||||
export default function ConditionalLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const hideSidebar = pathname === '/login' || pathname === '/register';
|
||||
|
||||
return hideSidebar ? <>{children}</> : <SidebarLayout>{children}</SidebarLayout>;
|
||||
}
|
||||
38
src/components/vuln/ClientVulnSection.tsx
Normal file
38
src/components/vuln/ClientVulnSection.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
import DeviceDropdown from '../DeviceDropdown';
|
||||
import VulnerabilityTableWithControls from './VulnerabilityTableWithControls';
|
||||
|
||||
interface Device {
|
||||
deviceId: number;
|
||||
hostname: string;
|
||||
lastCheckedIn: string;
|
||||
}
|
||||
|
||||
export default function ClientVulnSection({ devices }: { devices: Device[] }) {
|
||||
const [selectedDevice, setSelectedDevice] = useState<number | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeviceDropdown
|
||||
devices={devices}
|
||||
selectedDevice={selectedDevice}
|
||||
setSelectedDevice={setSelectedDevice}
|
||||
/>
|
||||
|
||||
{selectedDevice && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={() => setReloadKey((prev) => prev + 1)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition"
|
||||
>
|
||||
🔄 Re-check Database
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
src/components/vuln/VulnerabilityTable.tsx
Normal file
61
src/components/vuln/VulnerabilityTable.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
import {
|
||||
Table,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
interface Vulnerability {
|
||||
cveId: string;
|
||||
severity: string;
|
||||
score: number;
|
||||
description: string;
|
||||
affectedApp: string;
|
||||
installedVersion: string;
|
||||
}
|
||||
|
||||
export default function VulnerabilityTable({ vulnerabilities }: { vulnerabilities: Vulnerability[] }) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper} sx={{ mt: 4 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>CVE</TableCell>
|
||||
<TableCell>Severity</TableCell>
|
||||
<TableCell>Score</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell>App</TableCell>
|
||||
<TableCell>Installed Version</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{vulnerabilities.map((vuln, idx) => (
|
||||
<TableRow
|
||||
key={idx}
|
||||
hover
|
||||
sx={{
|
||||
'&:nth-of-type(even)': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableCell>{vuln.cveId}</TableCell>
|
||||
<TableCell>{vuln.severity}</TableCell>
|
||||
<TableCell>{vuln.score}</TableCell>
|
||||
<TableCell>{vuln.description}</TableCell>
|
||||
<TableCell>{vuln.affectedApp}</TableCell>
|
||||
<TableCell>{vuln.installedVersion}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
230
src/components/vuln/VulnerabilityTableWithControls.tsx
Normal file
230
src/components/vuln/VulnerabilityTableWithControls.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, TablePagination, TextField, TableSortLabel, Box
|
||||
} from '@mui/material';
|
||||
import { brandColors } from '@/app/styles/colors';
|
||||
import { useThemeMode } from '@/context/ThemeContext';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface CveMatchResult {
|
||||
cveId: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
score?: number;
|
||||
publishedDate: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
vulns: CveMatchResult[];
|
||||
}
|
||||
|
||||
export default function VulnerabilityTableWithControls({ vulns }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [orderBy, setOrderBy] = useState<keyof CveMatchResult>('publishedDate');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const { darkMode } = useThemeMode();
|
||||
|
||||
const columns: { key: keyof CveMatchResult; label: string }[] = [
|
||||
{ key: 'cveId', label: 'CVE ID' },
|
||||
{ key: 'title', label: 'Description' },
|
||||
{ key: 'severity', label: 'Severity' },
|
||||
{ key: 'score', label: 'CVSS' },
|
||||
{ key: 'publishedDate', label: 'Published' },
|
||||
];
|
||||
|
||||
const [columnWidths, setColumnWidths] = useState<Record<keyof CveMatchResult, number>>({
|
||||
cveId: 80,
|
||||
title: 300, // ← this acts like your "initial minWidth"
|
||||
severity: 60,
|
||||
score: 60,
|
||||
publishedDate: 100,
|
||||
});
|
||||
|
||||
|
||||
|
||||
const filteredAndSorted = useMemo(() => {
|
||||
let result = [...vulns];
|
||||
|
||||
if (search.trim()) {
|
||||
const lower = search.trim().toLowerCase();
|
||||
result = result.filter(row =>
|
||||
row.cveId.toLowerCase().includes(lower) ||
|
||||
row.title.toLowerCase().includes(lower) ||
|
||||
row.severity.toLowerCase().includes(lower)
|
||||
);
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
const aValue = a[orderBy];
|
||||
const bValue = b[orderBy];
|
||||
if (aValue == null) return 1;
|
||||
if (bValue == null) return -1;
|
||||
if (order === 'asc') return aValue > bValue ? 1 : -1;
|
||||
return aValue < bValue ? 1 : -1;
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [vulns, search, orderBy, order]);
|
||||
|
||||
const paginated = useMemo(() => {
|
||||
const start = page * pageSize;
|
||||
return filteredAndSorted.slice(start, start + pageSize);
|
||||
}, [filteredAndSorted, page, pageSize]);
|
||||
|
||||
const handleSort = (column: keyof CveMatchResult) => {
|
||||
const isAsc = orderBy === column && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(column);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const cellStyle = {
|
||||
border: '1px solid rgba(224, 224, 224, 1)',
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'left',
|
||||
};
|
||||
const createResizer = (key: keyof CveMatchResult) => {
|
||||
return (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = columnWidths[key];
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const newWidth = Math.max(50, startWidth + moveEvent.clientX - startX);
|
||||
setColumnWidths((prev) => ({ ...prev, [key]: newWidth }));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Box className="space-y-4">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search CVE ID, severity, or description..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ maxWidth: 400 }}
|
||||
/>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={filteredAndSorted.length}
|
||||
page={page}
|
||||
onPageChange={(_, newPage) => setPage(newPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setPageSize(parseInt(e.target.value, 10));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: darkMode ? brandColors.paperDark : brandColors.paperLight,
|
||||
}}
|
||||
>
|
||||
<TableContainer component={Paper} elevation={0} sx={{ backgroundColor: 'inherit' }}>
|
||||
<Table size="small" sx={{ tableLayout: 'fixed', width: '100%' }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map(({ key, label }) => (
|
||||
<TableCell
|
||||
key={key}
|
||||
sortDirection={orderBy === key ? order : false}
|
||||
sx={{
|
||||
...cellStyle,
|
||||
width: columnWidths[key],
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<TableSortLabel
|
||||
active={orderBy === key}
|
||||
direction={orderBy === key ? order : 'asc'}
|
||||
onClick={() => handleSort(key)}
|
||||
>
|
||||
{label}
|
||||
</TableSortLabel>
|
||||
<Box
|
||||
onMouseDown={createResizer(key)}
|
||||
sx={{
|
||||
width: '6px',
|
||||
cursor: 'col-resize',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
'&:hover': {
|
||||
backgroundColor: darkMode ? '#444' : '#ccc',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
|
||||
<TableBody>
|
||||
{paginated.map((row) => (
|
||||
<TableRow sx={{ border:'1px solid rgba(224, 224, 224, 1)',whiteSpace: 'nowrap', width: columnWidths.cveId, alignItems: 'center'}} key={row.cveId}>
|
||||
<TableCell sx={{ ...cellStyle, width: columnWidths.cveId }}>
|
||||
<a
|
||||
href={`https://nvd.nist.gov/vuln/detail/${row.cveId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: darkMode ? brandColors.linkDark : brandColors.linkLight,
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{row.cveId}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
border: '1px solid rgba(224, 224, 224, 1)',
|
||||
whiteSpace: 'normal', // ✅ allows wrapping
|
||||
overflowWrap: 'break-word', // ✅ ensures long words wrap
|
||||
wordBreak: 'break-word', // ✅ (optional, helps with unbreakable strings)
|
||||
textAlign: 'left',
|
||||
width: columnWidths.title,
|
||||
}}
|
||||
>
|
||||
{row.title}
|
||||
</TableCell>
|
||||
<TableCell sx={{...cellStyle, width: columnWidths.severity}}>{row.severity}</TableCell>
|
||||
<TableCell sx={{...cellStyle, width: columnWidths.score}}>{row.score !== null && row.score !== undefined ? row.score.toFixed(1) : 'N/A'}</TableCell>
|
||||
<TableCell sx={{...cellStyle, width: columnWidths.publishedDate}}>{format(new Date(row.publishedDate), 'PPP')}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
159
src/context/AuthContext.tsx
Normal file
159
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
//src/context/AuthContext.tsx
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { disableConsoleInProd } from '@/lib/disableConsole';
|
||||
disableConsoleInProd();
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
displayname: string;
|
||||
userId?: number;
|
||||
idauth?: string;
|
||||
exp: number;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
username: string;
|
||||
displayname: string;
|
||||
userId?: number;
|
||||
clientIdentifier?: string;
|
||||
roles?: string[]; // 👈 Add this
|
||||
loading: boolean;
|
||||
refreshAuth: () => Promise<void>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
username?: string;
|
||||
displayname?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider = ({
|
||||
children,
|
||||
username = '',
|
||||
displayname = '',
|
||||
roles = [],
|
||||
}: AuthProviderProps) => {
|
||||
const [authInfo, setAuthInfo] = useState<AuthContextType>(() => ({
|
||||
username,
|
||||
displayname,
|
||||
roles,
|
||||
userId: undefined,
|
||||
clientIdentifier: undefined,
|
||||
loading: true,
|
||||
authToken: undefined,
|
||||
refreshAuth: () => Promise.resolve(), // stub, will be replaced later
|
||||
}));
|
||||
|
||||
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const refreshAuth = async () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
console.log("🔁 [AuthContext] Running refreshAuth");
|
||||
|
||||
setAuthInfo((prev) => ({ ...prev, loading: true }));
|
||||
|
||||
const token = Cookies.get('authToken');
|
||||
console.log("🔐 Retrieved token from cookie:", token);
|
||||
|
||||
if (!token) {
|
||||
setAuthInfo({
|
||||
username: '',
|
||||
displayname: '',
|
||||
userId: undefined,
|
||||
clientIdentifier: undefined,
|
||||
roles: [],
|
||||
authToken: undefined,
|
||||
loading: false,
|
||||
refreshAuth, // <- update it here
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwtDecode<JwtPayload>(token);
|
||||
console.log("🎯 Decoded username:", decoded.sub);
|
||||
|
||||
setAuthInfo({
|
||||
username: decoded.sub,
|
||||
displayname: decoded.displayname,
|
||||
userId: decoded.userId,
|
||||
clientIdentifier: decoded.idauth,
|
||||
roles: decoded.roles ?? [],
|
||||
authToken: token,
|
||||
loading: false,
|
||||
refreshAuth, // ✅ inject real function now
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("❌ Failed to decode JWT in refreshAuth", err);
|
||||
setAuthInfo((prev) => ({ ...prev, loading: false, refreshAuth }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// ✅ Don't run refreshAuth if we already got username from SSR
|
||||
if (username && username !== 'user') {
|
||||
setAuthInfo((prev) => ({ ...prev, loading: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🏁 [AuthContext] Calling refreshAuth from useEffect");
|
||||
refreshAuth();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const token = Cookies.get('authToken');
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const decoded = jwtDecode<JwtPayload>(token);
|
||||
const now = Date.now() / 1000;
|
||||
if (decoded.exp < now) {
|
||||
localStorage.setItem('authRedirectReason', 'Session expired. Please log in again.');
|
||||
Cookies.remove('authToken');
|
||||
router.push('/login');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Token check failed', err);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...authInfo, refreshAuth }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
213
src/context/DeviceContext.tsx
Normal file
213
src/context/DeviceContext.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import api from '@/lib/axios';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { DetailedDevice } from '@/types/devices';
|
||||
import { disableConsoleInProd } from '@/lib/disableConsole';
|
||||
disableConsoleInProd();
|
||||
|
||||
|
||||
|
||||
interface DriveInfo {
|
||||
name: string;
|
||||
driveType: string;
|
||||
totalSizeGB: number;
|
||||
freeSpaceGB: number;
|
||||
}
|
||||
|
||||
interface IpAddress {
|
||||
interfaceName: string;
|
||||
ipAddress: string;
|
||||
}
|
||||
|
||||
interface MacAddress {
|
||||
interfaceName: string;
|
||||
macAddress: string;
|
||||
}
|
||||
|
||||
interface InstalledApp {
|
||||
app_name: string;
|
||||
app_version: string;
|
||||
publisher: string;
|
||||
}
|
||||
|
||||
|
||||
interface DeviceVulnerability {
|
||||
cveId: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
score?: number;
|
||||
publishedDate: string;
|
||||
lastModifiedDate: string;
|
||||
}
|
||||
|
||||
interface CachedSoftwareEntry {
|
||||
id: number;
|
||||
softwareName: string;
|
||||
hostname: string;
|
||||
version: string;
|
||||
deviceId: number;
|
||||
totalCves: number;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface DeviceContextType {
|
||||
devices: DetailedDevice[];
|
||||
deviceVulns: { [deviceId: string]: DeviceVulnerability[] };
|
||||
cachedSoftware: CachedSoftwareEntry[];
|
||||
detailedCveLookup: { [cveId: string]: DeviceVulnerability }; // 🔥 New
|
||||
setDevices: React.Dispatch<React.SetStateAction<DetailedDevice[]>>;
|
||||
setDeviceVulns: React.Dispatch<React.SetStateAction<{ [deviceId: string]: DeviceVulnerability[] }>>;
|
||||
setCachedSoftware: React.Dispatch<React.SetStateAction<CachedSoftwareEntry[]>>;
|
||||
setDetailedCveLookup: React.Dispatch<React.SetStateAction<{ [cveId: string]: DeviceVulnerability }>>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
|
||||
const DeviceContext = createContext<DeviceContextType | undefined>(undefined);
|
||||
|
||||
|
||||
export const useDeviceContext = () => {
|
||||
const context = useContext(DeviceContext);
|
||||
if (!context) {
|
||||
throw new Error('useDeviceContext must be used within a DeviceProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const DeviceProvider = ({
|
||||
children,
|
||||
initialData,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialData?: {
|
||||
devices: DetailedDevice[];
|
||||
vulnerabilitiesByDevice: { [deviceId: string]: DeviceVulnerability[] };
|
||||
};
|
||||
}) => {
|
||||
const [devices, setDevices] = useState<DetailedDevice[]>(() => {
|
||||
if (initialData?.devices?.length) return initialData.devices;
|
||||
return [];
|
||||
});
|
||||
|
||||
const [deviceVulns, setDeviceVulns] = useState<{ [deviceId: string]: DeviceVulnerability[] }>(
|
||||
initialData?.vulnerabilitiesByDevice ?? {}
|
||||
);
|
||||
|
||||
const [detailedCveLookup, setDetailedCveLookup] = useState<{ [cveId: string]: DeviceVulnerability }>({});
|
||||
|
||||
const [cachedSoftware, setCachedSoftware] = useState<CachedSoftwareEntry[]>([]); // ⬅️ NEW
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(!initialData);
|
||||
|
||||
const { username, roles, loading: authLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (devices.length > 0 || initialData || authLoading || !username) return;
|
||||
|
||||
const fetchDevicesVulnsAndSoftware = async () => {
|
||||
try {
|
||||
const isAdmin = roles?.includes('ADMIN');
|
||||
const devicesEndpoint = isAdmin
|
||||
? '/cached/devices/with-vulns/all'
|
||||
: '/cached/devices/with-vulns';
|
||||
const softwareEndpoint = '/cached/software/summary';
|
||||
|
||||
console.group('📡 Fetching Devices, Vulnerabilities, and Software');
|
||||
|
||||
const [devicesRes, softwareRes] = await Promise.all([
|
||||
api.get(devicesEndpoint, { withCredentials: true }),
|
||||
api.get(softwareEndpoint, { withCredentials: true }),
|
||||
]);
|
||||
|
||||
console.log('✅ Devices fetched:', devicesRes.data);
|
||||
console.log('✅ Software fetched:', softwareRes.data);
|
||||
console.groupEnd();
|
||||
|
||||
// STEP 1: Set basic data
|
||||
setDevices(devicesRes.data.devices);
|
||||
setDeviceVulns(devicesRes.data.vulnerabilitiesByDevice);
|
||||
setCachedSoftware(softwareRes.data);
|
||||
|
||||
// STEP 2: Extract all unique CVE IDs
|
||||
const uniqueCveIds = new Set<string>();
|
||||
(Object.values(devicesRes.data.vulnerabilitiesByDevice) as DeviceVulnerability[][]).forEach((vulns) => {
|
||||
vulns.forEach((vuln) => {
|
||||
if (vuln.cveId) {
|
||||
uniqueCveIds.add(vuln.cveId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// STEP 3: Fetch detailed CVE data
|
||||
if (uniqueCveIds.size > 0) {
|
||||
console.log('🔎 Fetching detailed CVEs for:', Array.from(uniqueCveIds));
|
||||
const params = new URLSearchParams();
|
||||
Array.from(uniqueCveIds).forEach(cveId => {
|
||||
params.append('cveIds', cveId);
|
||||
});
|
||||
|
||||
const cveDetailsRes = await api.get<DeviceVulnerability[]>('/vuln/cves/lookup', {
|
||||
params,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
const lookupMap: { [cveId: string]: DeviceVulnerability } = {};
|
||||
cveDetailsRes.data.forEach((cve) => {
|
||||
lookupMap[cve.cveId] = cve;
|
||||
});
|
||||
|
||||
setDetailedCveLookup(lookupMap);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.group('❌ Device fetch failed');
|
||||
console.error('🔴 Axios Error Message:', error.message);
|
||||
console.error('🧾 Axios Error Config:', error.config);
|
||||
if (error.response) {
|
||||
console.error('📉 Status:', error.response.status);
|
||||
console.error('📄 Response Headers:', error.response.headers);
|
||||
console.error('📄 Response Data:', error.response.data);
|
||||
} else if (error.request) {
|
||||
console.error('🛑 No response received:', error.request);
|
||||
}
|
||||
console.groupEnd();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
fetchDevicesVulnsAndSoftware();
|
||||
}, [devices.length, initialData, username, authLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("🧠 Devices updated:", devices);
|
||||
}, [devices]);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).__debug_devices = devices;
|
||||
}
|
||||
|
||||
return (
|
||||
<DeviceContext.Provider value={{
|
||||
devices,
|
||||
deviceVulns,
|
||||
cachedSoftware,
|
||||
detailedCveLookup, // ✅ provide it!
|
||||
setDevices,
|
||||
setDeviceVulns,
|
||||
setCachedSoftware,
|
||||
setDetailedCveLookup, // ✅ provide this too!
|
||||
loading,
|
||||
}}>
|
||||
|
||||
{children}
|
||||
</DeviceContext.Provider>
|
||||
);
|
||||
};
|
||||
43
src/context/ThemeContext.tsx
Normal file
43
src/context/ThemeContext.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// src/context/ThemeContext.tsx
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { disableConsoleInProd } from '@/lib/disableConsole';
|
||||
disableConsoleInProd();
|
||||
|
||||
interface ThemeContextType {
|
||||
darkMode: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const ThemeProviderCustom = ({ children }: { children: React.ReactNode }) => {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('theme');
|
||||
const prefersDark = stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
setDarkMode(prefersDark);
|
||||
document.documentElement.classList.toggle('dark', prefersDark);
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !darkMode;
|
||||
setDarkMode(next);
|
||||
document.documentElement.classList.toggle('dark', next);
|
||||
localStorage.setItem('theme', next ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ darkMode, toggle }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useThemeMode = () => {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useThemeMode must be used within ThemeProviderCustom');
|
||||
return ctx;
|
||||
};
|
||||
53
src/lib/auth.ts
Normal file
53
src/lib/auth.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// src/lib/auth.ts
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import Cookies from 'js-cookie'; // only usable on the client
|
||||
|
||||
interface JwtPayload {
|
||||
exp: number;
|
||||
sub: string;
|
||||
displayname: string;
|
||||
idauth?: string;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
export function getStoredToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return Cookies.get('authToken') ?? null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getUserInfoFromToken(token: string) {
|
||||
try {
|
||||
const decoded = jwtDecode<JwtPayload>(token);
|
||||
return {
|
||||
username: decoded.sub,
|
||||
userId: decoded.userId,
|
||||
displayname: decoded.displayname,
|
||||
clientIdentifier: decoded.idauth
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isExpiredToken(token: string): boolean {
|
||||
try {
|
||||
const decoded = jwtDecode<JwtPayload>(token);
|
||||
return Date.now() >= decoded.exp * 1000;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredToken() {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('authToken');
|
||||
}
|
||||
}
|
||||
export function getAuthHeader() {
|
||||
const token = getStoredToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
|
||||
14
src/lib/authMessages.ts
Normal file
14
src/lib/authMessages.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// src/lib/authMessages.ts
|
||||
export const getToastMessage = (reason: string | null): string | null => {
|
||||
switch (reason) {
|
||||
case 'session-expired':
|
||||
return 'Session expired. Please log in again.';
|
||||
case 'unauthorized':
|
||||
return 'You must be logged in to access this page.';
|
||||
case 'invalid-token':
|
||||
return 'Invalid or malformed token. Please log in again.';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
49
src/lib/authServer.ts
Normal file
49
src/lib/authServer.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// src/lib/authServer.ts
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
interface JwtPayload {
|
||||
exp: number;
|
||||
sub: string;
|
||||
displayname: string;
|
||||
idauth?: string;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
export async function getTokenFromCookies(): Promise<string | null> {
|
||||
return (async () => {
|
||||
const cookieStore = await cookies();
|
||||
return cookieStore.get('authToken')?.value ?? null;
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
// ✅ Must be async now to await token
|
||||
export async function getUserFromServerToken(): Promise<JwtPayload | null> {
|
||||
const token = await getTokenFromCookies();
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
return jwtDecode<JwtPayload>(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Must be async now to await token
|
||||
export async function requireAuthOrRedirect(): Promise<JwtPayload & { token: string }> {
|
||||
const token = await getTokenFromCookies();
|
||||
if (!token) redirect('/login?reason=unauthorized');
|
||||
|
||||
try {
|
||||
const decoded = jwtDecode<JwtPayload>(token);
|
||||
const isExpired = Date.now() >= decoded.exp * 1000;
|
||||
if (isExpired) redirect('/login?reason=session-expired');
|
||||
|
||||
return { ...decoded, token };
|
||||
} catch {
|
||||
redirect('/login?reason=invalid-token');
|
||||
}
|
||||
}
|
||||
33
src/lib/axios.ts
Normal file
33
src/lib/axios.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from "axios";
|
||||
import { clearStoredToken } from "./auth";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '/api',
|
||||
withCredentials: true, // ✅ Still correct
|
||||
});
|
||||
|
||||
// Log requests (but don’t attach token)
|
||||
api.interceptors.request.use((config) => {
|
||||
console.log('📤 [Axios] Request:', config.method?.toUpperCase(), config.url);
|
||||
console.log('📤 [Axios] Headers before sending:', config.headers);
|
||||
return config;
|
||||
});
|
||||
|
||||
// Global error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log('✅ [Axios] Response:', response.status, response.config.url);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ [Axios] Error Response:', error.response?.status, error.response?.config?.url);
|
||||
if (error.response?.status === 401) {
|
||||
console.warn("🔐 Token expired or unauthorized. Redirecting to login.");
|
||||
clearStoredToken();
|
||||
window.location.href = "/login?reason=session-expired";
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
8
src/lib/axiosServer.ts
Normal file
8
src/lib/axiosServer.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// src/lib/axiosServer.ts
|
||||
import axios from 'axios';
|
||||
|
||||
const axiosServer = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080/api',
|
||||
});
|
||||
|
||||
export default axiosServer;
|
||||
7
src/lib/disableConsole.ts
Normal file
7
src/lib/disableConsole.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/lib/disableConsole.ts
|
||||
export function disableConsoleInProd() {
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'development') {
|
||||
console.log = console.debug = console.info = console.group = console.groupCollapsed = console.groupEnd = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
45
src/types/devices.ts
Normal file
45
src/types/devices.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// src/types/devices.ts
|
||||
export interface DriveInfo {
|
||||
name: string;
|
||||
driveType: string;
|
||||
totalSizeGB: number;
|
||||
freeSpaceGB: number;
|
||||
}
|
||||
|
||||
export interface IpAddress {
|
||||
interfaceName: string;
|
||||
ipAddress: string;
|
||||
}
|
||||
|
||||
export interface MacAddress {
|
||||
interfaceName: string;
|
||||
macAddress: string;
|
||||
}
|
||||
|
||||
export interface InstalledApp {
|
||||
app_name: string;
|
||||
app_version: string;
|
||||
publisher: string;
|
||||
}
|
||||
|
||||
export interface DetailedDevice {
|
||||
deviceId: number;
|
||||
hostname: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
windowsVersion: string;
|
||||
windowsBuild: string;
|
||||
osArchitecture: string;
|
||||
processorName: string;
|
||||
processorArchitecture: string;
|
||||
gpuNames: string[];
|
||||
totalMemory: string;
|
||||
lastBootTime: string;
|
||||
lastCheckedIn: string;
|
||||
drives: DriveInfo[];
|
||||
ipAddresses: IpAddress[];
|
||||
macAddresses: MacAddress[];
|
||||
installedApplications: InstalledApp[];
|
||||
clientName?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user