David Headrick 0e099bfd07 Enhance authentication and logging mechanisms
Updated authentication handling in controllers, added JWT support, and improved error logging. Introduced centralized API calls with customFetch for better token management. Added Grafana's Faro SDK for monitoring and tracing. Refactored project files for improved structure and maintainability.
2025-05-19 17:26:37 -05:00

361 lines
16 KiB
TypeScript

// src/components/layouts/Layout.tsx
import { useTitle } from "@/context/TitleContext";
import { routeRoleRequirements } from '@/components/auth/ProtectedPageWrapper';
import { useAuth } from '@/components/auth/AuthContext';
import React, { ReactNode, useEffect } from 'react';
import { useTheme, useMediaQuery } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { styled, useColorScheme } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import List from '@mui/material/List';
import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import DashboardIcon from '@mui/icons-material/Dashboard';
import HttpIcon from '@mui/icons-material/Http';
import AccountBoxIcon from '@mui/icons-material/AccountBox';
import CancelIcon from '@mui/icons-material/Cancel';
import DnsIcon from '@mui/icons-material/Dns';
import TargetIcon from '@mui/icons-material/TrackChanges';
import MarkEmailReadIcon from '@mui/icons-material/MarkEmailRead';
import BlockIcon from '@mui/icons-material/Block';
//import LinkOffIcon from '@mui/icons-material/LinkOff';
import EmailIcon from '@mui/icons-material/Email';
import SendIcon from '@mui/icons-material/Send';
import ScheduleSendIcon from '@mui/icons-material/ScheduleSend';
import AutorenewIcon from '@mui/icons-material/Autorenew';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { Link as RouterLink } from 'react-router-dom';
import Select, { SelectChangeEvent } from '@mui/material/Select';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import { useCustomFetch } from "@/utils/customFetch";
// Constants
const drawerWidth = 240;
// Styled components
const DrawerHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
padding: theme.spacing(0, 1),
...theme.mixins.toolbar,
justifyContent: 'flex-end',
}));
const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })<{
open?: boolean;
}>(({ theme, open }) => ({
flexGrow: 1,
padding: theme.spacing(3),
transition: theme.transitions.create(['margin', 'width', 'padding'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
marginLeft: "0px !important", // Force remove any margin on the left
marginRight: "0px !important", // Force remove any margin on the left
...(open && {/*Opened specific types go here*/}),
...(!open && {/*closed specific styles go here*/})
}));
interface LayoutProps {
children: ReactNode;
}
const Layout = ({ children }: LayoutProps) => {
const customFetch = useCustomFetch();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); //TODO: Move this to shared utils?
const [open, setOpen] = React.useState(!isMobile);
const { mode, setMode } = useColorScheme(); // MUI v6 hook for theme switching
const iconButtonRef = React.useRef<HTMLButtonElement>(null);
const { title } = useTitle();
const navigate = useNavigate();
const menuItems = [
{ text: 'Home', icon: <DashboardIcon />, path: '/home' },
{ text: 'Servers', icon: <DnsIcon />, path: '/servers' },
{ text: 'Targets', icon: <TargetIcon />, path: '/targets' },
{ text: 'Test Lists', icon: <MarkEmailReadIcon />, path: '/testEmailLists' },
{ text: 'Blocked Emails', icon: <BlockIcon />, path: '/blockedEmails' },
{ text: 'Email Domains', icon: <HttpIcon />, path: '/emailDomains' },
{ text: 'Templates', icon: <EmailIcon />, path: '/templates' },
{ text: 'New Mailings', icon: <SendIcon />, path: '/newMailings' },
{ text: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' },
{ text: 'Active Mailings', icon: <AutorenewIcon />, path: '/activeMailings' },
{ text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' },
{ text: 'Cancelled Mailings', icon: <CancelIcon />, path: '/cancelledMailings' },
];
const { userRoles, setAuth } = useAuth(); // Use context
const [profileMenuAnchorEl, setProfileMenuAnchorEl] = React.useState<null | HTMLElement>(null);
const profileMenuOpen = Boolean(profileMenuAnchorEl);
const handleOpenProfileMenu = (event: React.MouseEvent<HTMLButtonElement>) => {
setProfileMenuAnchorEl(event.currentTarget);
};
const handleCloseProfileMenu = () => {
setProfileMenuAnchorEl(null);
};
const visibleMenuItems = menuItems.filter(item => {
const requiredRoles = routeRoleRequirements[item.path] || [];
return requiredRoles.length == 0 || requiredRoles.some(role => userRoles.includes(role));
});
const handleRefreshUser = async () => {
handleCloseProfileMenu();
try {
const response = await customFetch('/api/authentication/refreshtoken', {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
setAuth(data.accessToken); // Update context
} else {
setAuth(null); // Clear context on failure
navigate('/login');
}
} catch {
setAuth(null); // Clear context on failure
navigate('/login');
}
}
const handleLogout = async () => {
setAuth(null); // Clear context
await customFetch('/api/authentication/logout', { method: 'POST', credentials: 'include' });
navigate('/login');
};
const handleDrawerOpen = () => {
setOpen(true);
};
const handleDrawerClose = () => {
setOpen(false);
};
useEffect(() => {
if (isMobile) {
setOpen(false);
}
}, [isMobile]);
const handleThemeChange = (event: SelectChangeEvent) => {
setMode(event.target.value as 'light' | 'dark');
if (iconButtonRef.current) {
const selectElement = iconButtonRef.current;
if (selectElement) {
if (selectElement instanceof HTMLElement) {
setTimeout(() => {
selectElement.focus(); // Blur the focusable input
}, 0);
}
}
}
};
return (
<Box sx={{ display: 'flex' }}>
{/* App Bar */}
<AppBar
position="fixed"
sx={(theme) => ({
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(['width', 'margin', 'padding'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.leavingScreen,
}),
...(open && {
width: isMobile ? "100%" : `calc(100% - ${drawerWidth}px)`,
ml: `${drawerWidth}px`,
transition: theme.transitions.create(['width', 'margin', 'padding'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.enteringScreen,
}),
}),
})}
>
<Toolbar>
{isMobile && open ?
<IconButton
color="inherit"
aria-label="close drawer"
onClick={handleDrawerClose}
edge="start"
ref={iconButtonRef}
sx={{ mr: 2 }}
>
<ChevronLeftIcon />
</IconButton> :
<IconButton
color="inherit"
aria-label="open drawer"
onClick={handleDrawerOpen}
edge="start"
ref={iconButtonRef}
sx={{ mr: 2, ...(open && { display: 'none' }) }}
>
<MenuIcon />
</IconButton>
}
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title}
</Typography>
<FormControl sx={{ minWidth: 120 }} size="small">
<InputLabel
id="theme-select-label"
sx={{
color: 'white', // White in both modes
'&.Mui-focused': { color: 'white' }, // Keep white when focused
}}
>
Theme
</InputLabel>
<Select
labelId="theme-select-label"
id="theme-select"
value={mode || 'light'}
label="Theme"
onChange={handleThemeChange}
sx={{
color: 'white', // White text
'& .MuiSvgIcon-root': { color: 'white' }, // White dropdown arrow
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'white',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'white',
borderWidth: 2
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'white',
borderWidth: 2
},
}}
>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<IconButton
color="inherit"
id="profile-menu-button"
aria-label="Profile Menu"
aria-controls={open ? 'profile-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleOpenProfileMenu}
sx={{ mr: 2 }}
>
<AccountBoxIcon />
</IconButton>
<Menu
id="profile-menu"
anchorEl={profileMenuAnchorEl}
open={profileMenuOpen}
onClose={handleCloseProfileMenu}
>
<MenuItem onClick={handleRefreshUser}>Refresh User</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
</FormControl>
</Toolbar>
</AppBar>
{/* Sidebar */}
<Drawer
variant={isMobile ? "temporary" : "persistent"}
anchor="left"
open={open}
onClose={handleDrawerClose}
ModalProps={{
keepMounted: true, // Keep mounted to avoid re-render issues
hideBackdrop: isMobile && !open, // Explicitly hide backdrop when closed in mobile
}}
sx={{
width: isMobile ? "100%" : open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: isMobile ? "100%" : open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
boxSizing: 'border-box',
},
}}
>
<DrawerHeader>
<IconButton onClick={handleDrawerClose}>
<ChevronLeftIcon />
</IconButton>
</DrawerHeader>
<Divider />
<List>
{/*{[*/}
{/* { text: 'Home', icon: <DashboardIcon />, path: '/home' },*/}
{/* { text: 'Servers', icon: <DnsIcon />, path: '/servers' },*/}
{/* { text: 'Targets', icon: <TargetIcon />, path: '/targets' },*/}
{/* { text: 'Test Lists', icon: <MarkEmailReadIcon />, path: '/testEmailLists' },*/}
{/* { text: 'Blocked Emails', icon: <BlockIcon />, path: '/blockedEmails' },*/}
{/* { text: 'Email Domains', icon: <HttpIcon />, path: '/emailDomains' },*/}
{/* //{ text: 'Unsubscribe Urls', icon: <LinkOffIcon />, path: '/unsubscribeUrls' },*/}
{/* { text: 'Templates', icon: <EmailIcon />, path: '/templates' },*/}
{/* { text: 'New Mailings', icon: <SendIcon />, path: '/newMailings' }, //TODO: Maybe move all mailings to same page? Mailing stats on dashboard?*/}
{/* { text: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' }, //*/}
{/* { text: 'Active Mailings', icon: <AutorenewIcon />, path: '/activeMailings' },*/}
{/* { text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' },*/}
{/*].map((item) => (}*/}
{visibleMenuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
component={RouterLink}
to={item.path}
selected={location.pathname === item.path}
onClick={() => isMobile && handleDrawerClose()}
sx={{
'&.Mui-selected': {
backgroundColor: 'primary.main',
color: 'primary.contrastText',
'& .MuiListItemIcon-root': {
color: 'primary.contrastText',
},
},
'&.Mui-selected:hover': {
backgroundColor: 'primary.dark',
},
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Drawer>
{/* Main Content */}
<Main open={open}>
<DrawerHeader />
{children}
</Main>
</Box>
);
};
export default Layout;