Initial commit - frontend

This commit is contained in:
Bailey Taylor
2025-09-19 03:26:52 +00:00
commit 27e9a08ee0
234 changed files with 32097 additions and 0 deletions

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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 />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 });
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

146
src/app/globals.css Normal file
View 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
View 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
View 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
View 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;
}

View 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 dont 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
View 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);
});

View 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);
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
src/app/styles/colors.ts Normal file
View 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)
};

View 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
}

View 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';
}
}

View 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,
};
}

View 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;
}

View 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}</>;
}

View 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>
);
}

View 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;

View 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>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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,
},
},
},
});

View 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>
);
}

View 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>');
}

View 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;

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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, its 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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>;
}

View 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>
</>
)}
</>
);
}

View 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>
);
}

View 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
View 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;
};

View 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>
);
};

View 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
View 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
View 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
View 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
View 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 dont 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
View 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;

View 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
View 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;
}