larxius's picture
Update frontend/src/components/Layout.jsx
6f60a4c verified
Raw
History Blame Contribute Delete
28.9 kB
/* eslint-disable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect, react-hooks/immutability, react-refresh/only-export-components */
import { useState, useEffect, useRef } from 'react';
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
export const getInitials = (userData) => {
if (!userData) return 'DP';
const firstName = (userData.first_name || '').trim();
const lastName = (userData.last_name || '').trim();
if (firstName || lastName) {
const f = firstName.charAt(0);
const l = lastName.charAt(0);
if (f && l) return `${f}${l}`.toUpperCase();
if (f) return f.toUpperCase();
if (l) return l.toUpperCase();
}
const name = (userData.name || userData.full_name || '').trim();
if (name) {
const parts = name.split(/\s+/);
if (parts.length >= 2) {
return `${parts[0].charAt(0)}${parts[parts.length - 1].charAt(0)}`.toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
const email = (userData.email || '').trim();
if (email) {
const username = email.split('@')[0];
const parts = username.split(/[._-]/);
if (parts.length >= 2 && parts[0] && parts[1]) {
return `${parts[0].charAt(0)}${parts[1].charAt(0)}`.toUpperCase();
}
if (username.length >= 2) {
return username.substring(0, 2).toUpperCase();
}
return username.charAt(0).toUpperCase();
}
return 'DP';
};
export const Layout = ({ children }) => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const urlQuery = searchParams.get('q') || '';
const [globalSearchQuery, setGlobalSearchQuery] = useState(urlQuery);
useEffect(() => {
setGlobalSearchQuery(urlQuery);
}, [urlQuery]);
// Always scroll to top of page on route change
useEffect(() => {
window.scrollTo(0, 0);
const mainElement = document.querySelector('main');
if (mainElement) mainElement.scrollTop = 0;
}, [location.pathname]);
const [isDemoMode, setIsDemoMode] = useState(false);
const [isProfileOpen, setIsProfileOpen] = useState(false);
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
const [loadingNotifications, setLoadingNotifications] = useState(false);
const [hasUnreadNotifications, setHasUnreadNotifications] = useState(false);
const notificationsRef = useRef(null);
const profileRef = useRef(null);
useEffect(() => {
const handleClickOutside = (event) => {
if (notificationsRef.current && !notificationsRef.current.contains(event.target)) {
setIsNotificationsOpen(false);
}
if (profileRef.current && !profileRef.current.contains(event.target)) {
setIsProfileOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const fetchNotifications = async () => {
if (!user) return;
setLoadingNotifications(true);
try {
const token = localStorage.getItem('wss_token') || sessionStorage.getItem('wss_token');
const res = await fetch('/api/auth/notifications', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
const fetched = data.notifications || [];
const lastSeenId = localStorage.getItem('last_seen_notification_id');
if (fetched.length > 0 && String(fetched[0].id) !== lastSeenId) {
setHasUnreadNotifications(true);
}
setNotifications(fetched);
}
} catch (err) {
console.error("Failed to fetch notifications:", err);
} finally {
setLoadingNotifications(false);
}
};
useEffect(() => {
if (user) {
fetchNotifications();
const interval = setInterval(fetchNotifications, 60000); // refresh every minute
return () => clearInterval(interval);
}
}, [user]);
useEffect(() => {
setIsDemoMode(!!window.WSS_DEMO_MODE);
const checkInterval = setInterval(() => {
setIsDemoMode(!!window.WSS_DEMO_MODE);
}, 1000);
return () => clearInterval(checkInterval);
}, []);
// Force removal of dark theme and clear localStorage keys
useEffect(() => {
document.documentElement.classList.remove('dark');
localStorage.removeItem('color-theme');
}, []);
const [impersonationToken, setImpersonationToken] = useState(null);
const [organizations, setOrganizations] = useState([]);
useEffect(() => {
setImpersonationToken(localStorage.getItem('original_admin_token'));
}, [location.pathname]);
useEffect(() => {
const fetchOrgs = async () => {
const adminToken = localStorage.getItem('original_admin_token') || localStorage.getItem('wss_token') || sessionStorage.getItem('wss_token');
// Only attempt if they might be an admin
if (!adminToken) return;
try {
const res = await fetch('/api/auth/organizations', {
headers: { 'Authorization': `Bearer ${adminToken}` }
});
if (res.ok) {
const data = await res.json();
setOrganizations(data.organizations || []);
}
} catch (err) {
console.error("Failed to fetch organizations for dropdown", err);
}
};
const isSuperAdmin = user?.role === 'super_admin' || user?.role === 'admin' || localStorage.getItem('original_admin_token');
if (isSuperAdmin) {
fetchOrgs();
}
}, [user]);
const handleReturnToAdmin = () => {
const orig = localStorage.getItem('original_admin_token');
if (orig) {
localStorage.removeItem('original_admin_token');
localStorage.setItem('wss_token', orig);
window.location.href = location.pathname;
}
};
const handleLogout = () => {
const isSuperAdmin = user?.role === 'super_admin' || sessionStorage.getItem('superAdminAuth') === 'true';
logout();
sessionStorage.removeItem('superAdminAuth');
localStorage.removeItem('original_admin_token');
navigate(isSuperAdmin ? '/' : '/login');
};
const navItems = [
...(user?.role === 'executive_user'
? [
{ to: '/scans/history', label: 'Organization Reports', icon: 'analytics' },
{ to: '/settings', label: 'Settings', icon: 'settings' },
]
: [
{ to: '/dashboard', label: 'Dashboard', icon: 'dashboard' },
{ to: '/scans/new', label: 'New Scan', icon: 'security' },
{ to: '/scans/history', label: 'Reports', icon: 'analytics' },
{ to: '/scans/results', label: 'Vulnerabilities', icon: 'bug_report' },
{ to: '/settings', label: 'Settings', icon: 'settings' },
]),
...(user?.role === 'super_admin'
? [{ to: '/super-admin', label: 'Global Management', icon: 'admin_panel_settings' }]
: user?.role === 'admin'
? [{ to: '/admin', label: 'Global Management', icon: 'admin_panel_settings' }]
: user?.role === 'support_engineer'
? [{ to: '/support', label: 'Global Management', icon: 'support_agent' }]
: []),
];
return (
<div className="bg-background text-on-background font-body-md text-body-md antialiased min-h-screen flex flex-col md:flex-row w-full transition-colors duration-300">
{/* SideNavBar (Stitch Layout) */}
<nav className="bg-white dark:bg-inverse-surface h-screen w-64 flex-col fixed left-0 top-0 border-r border-outline-variant dark:border-outline z-40 hidden md:flex py-lg px-md gap-sm text-left">
{/* Header Branding */}
<div className="mb-xl flex flex-col gap-sm">
<div className="flex items-center gap-sm">
<div className="h-12 flex items-center justify-center shrink-0">
<img src="/logo.png" alt="LarShield Logo" className="h-full object-contain" />
</div>
<div className="flex flex-col justify-center">
<h1 className="font-extrabold tracking-tight m-0 text-[18px] leading-none flex items-center">
<span className="text-[#0b132a] dark:text-white">Lar</span>
<span className="text-[#5856d6]">Shield</span>
</h1>
<p className="text-[12px] text-[#64748b] dark:text-slate-400 uppercase tracking-[0.14em] m-0 font-medium leading-tight mt-1">
{user?.role === 'super_admin' ? 'SUPER ADMIN' :
user?.role === 'admin' ? 'ADMIN' :
user?.role === 'support_engineer' ? 'SUPPORT ENGINEER' :
user?.role === 'executive_user' ? 'EXECUTIVE USER' :
user?.role === 'soc_analyst' ? 'SOC ANALYST' :
'ORG ADMIN'}
</p>
</div>
</div>
{user?.role !== 'executive_user' && (
<NavLink
to="/scans/new"
className="mt-md w-full bg-primary text-on-primary rounded-lg py-sm px-md font-label-md text-label-md flex items-center justify-center gap-xs hover:opacity-90 transition-opacity border-0 cursor-pointer"
style={{ textDecoration: 'none' }}
>
<span className="material-symbols-outlined" style={{ fontSize: '18px' }}>add</span>
Quick Scan
</NavLink>
)}
</div>
{/* Navigation Links */}
<div className="flex flex-col gap-base flex-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex items-center gap-sm px-sm py-sm rounded-lg transition-colors cursor-pointer transition-all duration-200 border-0 ${
isActive
? 'text-primary dark:text-primary-fixed-dim font-bold bg-surface-container-high dark:bg-on-secondary-fixed-variant'
: 'text-on-surface-variant dark:text-surface-variant hover:bg-surface-container-high dark:hover:bg-on-secondary-fixed-variant'
}`
}
style={{ textDecoration: 'none' }}
>
<span className="material-symbols-outlined">{item.icon}</span>
<span className="font-label-md text-label-md">{item.label}</span>
</NavLink>
))}
{/* Help Page Link */}
<NavLink
to="/help"
className={({ isActive }) =>
`flex items-center gap-sm px-sm py-sm rounded-lg transition-colors cursor-pointer transition-all duration-200 mt-auto border-0 ${
isActive
? 'text-primary dark:text-primary-fixed-dim font-bold bg-surface-container-high dark:bg-on-secondary-fixed-variant'
: 'text-on-surface-variant dark:text-surface-variant hover:bg-surface-container-high dark:hover:bg-on-secondary-fixed-variant'
}`
}
style={{ textDecoration: 'none' }}
>
<span className="material-symbols-outlined">help</span>
<span className="font-label-md text-label-md">Help</span>
</NavLink>
</div>
{/* Sidebar Status / Log Out */}
<div className="mt-md pt-md border-t border-outline-variant">
<button
onClick={handleLogout}
className="w-full flex items-center gap-sm px-sm py-sm rounded-lg text-on-surface-variant dark:text-surface-variant hover:bg-surface-container-high dark:hover:bg-on-secondary-fixed-variant transition-colors cursor-pointer transition-all duration-200 border-0 bg-transparent text-left"
>
<span className="material-symbols-outlined">logout</span>
<span className="font-label-md text-label-md">Log Out</span>
</button>
</div>
</nav>
{/* TopNavBar (Stitch Layout) */}
<header className="bg-surface/80 dark:bg-inverse-surface/80 backdrop-blur-md fixed top-0 w-full z-50 border-b border-outline-variant dark:border-outline shadow-sm flex justify-between items-center h-16 px-gutter max-w-container-max mx-auto md:w-[calc(100%-16rem)] md:left-64 md:px-lg">
{/* Mobile Hamburger menu */}
<div className="md:hidden flex items-center gap-sm">
<span className="material-symbols-outlined text-primary cursor-pointer">menu</span>
<div className="flex items-center gap-xs">
<img src="/logo.png" alt="LarShield Logo" className="h-7 w-7 object-contain" />
<div className="flex flex-col justify-center">
<span className="font-extrabold tracking-tight text-[15px] leading-none flex items-center">
<span className="text-[#0b132a] dark:text-white">Lar</span>
<span className="text-[#5856d6]">Shield</span>
</span>
<span className="text-[10px] text-[#64748b] dark:text-slate-400 uppercase tracking-[0.12em] font-medium leading-none mt-0.5">
{user?.role === 'super_admin' ? 'SUPER ADMIN' :
user?.role === 'admin' ? 'ADMIN' :
user?.role === 'support_engineer' ? 'SUPPORT ENGINEER' :
user?.role === 'executive_user' ? 'EXECUTIVE USER' :
user?.role === 'soc_analyst' ? 'SOC ANALYST' :
'ORG ADMIN'}
</span>
</div>
</div>
</div>
{/* Search Bar Utility */}
<div className="hidden md:flex flex-1 max-w-md ml-xl relative">
<span className="material-symbols-outlined absolute left-sm top-1/2 -translate-y-1/2 text-outline" style={{ fontSize: '20px' }}>search</span>
<input
className="w-full bg-surface-container-lowest border border-outline-variant rounded-lg pl-xl pr-sm py-xs font-body-sm text-body-sm text-on-surface focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all placeholder:text-outline"
placeholder="Search vulnerabilities, reports, assets..."
type="text"
value={globalSearchQuery}
onChange={(e) => setGlobalSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (globalSearchQuery.trim()) {
navigate(`/scans/history?q=${encodeURIComponent(globalSearchQuery.trim())}`);
} else {
navigate(`/scans/history`);
}
}
}}
/>
</div>
{/* Right Nav Icons / Mode Selector */}
<div className="flex items-center gap-gutter ml-auto">
{/* Organization Display / Admin Dropdown */}
{(user?.role === 'admin' || user?.role === 'super_admin' || impersonationToken) && organizations.length > 0 ? (
<div className="flex items-center">
<select
className="bg-surface-container border border-outline-variant rounded-md px-sm py-xs font-label-sm text-on-surface focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary max-w-[200px] truncate"
value={impersonationToken ? (user?.org_id || '') : ''}
onChange={async (e) => {
const targetOrgId = e.target.value;
if (!targetOrgId) {
// Return to Admin
handleReturnToAdmin();
return;
}
try {
const activeToken = localStorage.getItem('original_admin_token') || localStorage.getItem('wss_token');
const res = await fetch(`/api/auth/impersonate/${targetOrgId}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${activeToken}` }
});
if (res.ok) {
const data = await res.json();
localStorage.setItem('original_admin_token', activeToken);
localStorage.setItem('wss_token', data.access_token);
window.location.href = location.pathname; // Reload current page with new token
}
} catch (err) {
console.error("Failed to impersonate from dropdown:", err);
}
}}
>
<option value="">-- Return to Admin --</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
) : user?.org_name ? (
<div className="hidden md:flex items-center gap-xs font-label-md text-label-md text-on-surface bg-surface-container-low px-sm py-xs rounded-md border border-outline-variant">
<span className="material-symbols-outlined text-[18px] text-primary">domain</span>
<span className="font-bold">{user.org_name}</span>
</div>
) : null}
{/* Active Status Badge */}
{isDemoMode ? (
<div className="hidden md:inline-flex items-center gap-xs bg-yellow-500/10 border border-yellow-500/30 rounded-full px-sm py-[2px] font-label-sm text-label-sm text-yellow-600 dark:text-yellow-500 font-bold uppercase tracking-wider">
<span className="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span> Sandbox Mode
</div>
) : (
<div className="hidden md:inline-flex items-center gap-xs bg-green-500/10 border border-green-500/30 rounded-full px-sm py-[2px] font-label-sm text-label-sm text-green-600 dark:text-green-500 font-bold uppercase tracking-wider">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span> Connected
</div>
)}
{/* Profile & Controls */}
<div className="flex items-center gap-sm">
<div className="relative" ref={notificationsRef}>
<button
onClick={() => {
const willOpen = !isNotificationsOpen;
setIsNotificationsOpen(willOpen);
setIsProfileOpen(false);
if (willOpen) {
setHasUnreadNotifications(false);
if (notifications.length > 0) {
localStorage.setItem('last_seen_notification_id', String(notifications[0].id));
}
}
}}
className="text-on-surface-variant dark:text-surface-variant hover:text-primary transition-colors duration-200 active:opacity-80 transition-all flex items-center justify-center relative border-0 bg-transparent cursor-pointer"
>
<span className="material-symbols-outlined text-[26px]">notifications</span>
{hasUnreadNotifications && (
<span className="absolute top-0 right-0 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-error opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-error border-[1.5px] border-white dark:border-inverse-surface"></span>
</span>
)}
</button>
{isNotificationsOpen && (
<div className="absolute right-0 mt-sm w-80 bg-surface border border-outline-variant rounded-xl shadow-xl z-50 flex flex-col overflow-hidden">
<div className="px-md py-md border-b border-outline-variant bg-surface/95 backdrop-blur-sm flex justify-between items-center z-10 shrink-0">
<h3 className="font-label-lg text-label-lg font-bold text-on-surface m-0 flex items-center gap-xs">
<span className="material-symbols-outlined text-primary text-xl">notifications</span>
Notifications
</h3>
<button
onClick={fetchNotifications}
className="border border-outline-variant/50 bg-surface-container-lowest hover:bg-surface-container rounded-full w-8 h-8 flex items-center justify-center cursor-pointer transition-all text-on-surface-variant hover:text-primary shadow-sm"
title="Refresh Notifications"
>
<span className={"material-symbols-outlined text-base" + (loadingNotifications ? " animate-spin" : "")}>sync</span>
</button>
</div>
<div className="flex flex-col bg-surface-container-lowest overflow-hidden">
{loadingNotifications && notifications.length === 0 ? (
<div className="p-xl flex-1 flex flex-col justify-center items-center text-on-surface-variant font-body-sm gap-sm">
<span className="material-symbols-outlined animate-spin text-3xl text-primary/60">sync</span>
<span>Loading notifications...</span>
</div>
) : notifications.length === 0 ? (
<div className="p-xl flex-1 flex flex-col justify-center items-center text-on-surface-variant font-body-sm gap-xs text-center">
<div className="w-12 h-12 rounded-full bg-surface-container flex items-center justify-center mb-sm shadow-inner border border-outline-variant/30">
<span className="material-symbols-outlined text-3xl text-on-surface-variant/60">notifications_off</span>
</div>
<span className="font-bold text-on-surface font-label-md">All Caught Up!</span>
<span className="text-xs opacity-80 mt-1">You have no new notifications right now.</span>
</div>
) : (
notifications.slice(0, 3).map(n => (
<div key={n.id} className="flex gap-md p-md border-b border-outline-variant/40 hover:bg-surface-container-low transition-colors cursor-default text-left group">
<div className={`w-10 h-10 rounded-full flex items-center justify-center shrink-0 shadow-sm ${n.bg || 'bg-primary/10'}`}>
<span className={`material-symbols-outlined text-[20px] ${n.color || 'text-primary'}`}>{n.icon}</span>
</div>
<div className="flex flex-col flex-1 justify-center">
<span className="font-label-md text-label-md font-bold text-on-surface group-hover:text-primary transition-colors">{n.title}</span>
<span className="font-body-sm text-[13px] text-on-surface-variant leading-snug mt-[2px]">{n.message}</span>
<span className="font-label-sm text-[11px] text-on-surface-variant/60 mt-xs uppercase tracking-wider">{new Date(n.timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
{!user && (
<button className="flex items-center gap-xs text-primary font-label-md text-label-md hover:bg-primary/5 px-sm py-xs rounded-lg transition-colors border-0 bg-transparent cursor-pointer">
<span className="font-bold">Book Demo</span>
<span className="material-symbols-outlined" style={{ fontSize: '18px' }}>calendar_today</span>
</button>
)}
{user && (
<div className="relative ml-sm" ref={profileRef}>
<button
onClick={() => {
setIsProfileOpen(!isProfileOpen);
setIsNotificationsOpen(false);
}}
className="flex items-center gap-xs border-0 bg-transparent cursor-pointer hover:opacity-80 transition-opacity"
>
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary border border-primary/20 flex items-center justify-center font-bold text-xs uppercase shadow-sm">
{getInitials(user)}
</div>
<span className="hidden lg:inline text-xs font-semibold text-on-surface-variant dark:text-surface-variant">
{user.first_name || user.last_name ? `${user.first_name || ''} ${user.last_name || ''}`.trim() : user.email.split('@')[0]}
</span>
</button>
{isProfileOpen && (
<div className="absolute right-0 mt-xs w-52 bg-surface border border-outline-variant rounded-lg shadow-lg overflow-hidden z-50">
<div className="px-md py-sm border-b border-outline-variant bg-surface-container-low flex items-center gap-sm">
<div className="w-9 h-9 rounded-full bg-primary/10 text-primary border border-primary/20 flex items-center justify-center font-bold text-sm uppercase shrink-0">
{getInitials(user)}
</div>
<div className="flex flex-col min-w-0">
<p className="font-label-md text-label-md font-bold text-on-surface truncate m-0">
{user.first_name || user.last_name ? `${user.first_name || ''} ${user.last_name || ''}`.trim() : user.email}
</p>
<p className="font-body-sm text-[11px] text-on-surface-variant capitalize m-0 truncate">{(user.role || 'User').replace(/_/g, ' ')}</p>
</div>
</div>
<div className="p-xs">
<button
onClick={() => {
setIsProfileOpen(false);
navigate('/settings');
}}
className="w-full text-left px-sm py-xs font-label-md text-label-md text-on-surface hover:bg-surface-container rounded transition-colors flex items-center gap-xs border-0 bg-transparent cursor-pointer"
>
<span className="material-symbols-outlined text-[18px]">person</span>
My Profile
</button>
<button
onClick={handleLogout}
className="w-full text-left px-sm py-xs font-label-md text-label-md text-error hover:bg-error/10 rounded transition-colors flex items-center gap-xs border-0 bg-transparent cursor-pointer mt-xs"
>
<span className="material-symbols-outlined text-[18px]">logout</span>
Logout
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</header>
{/* Main Content Render Area */}
<main className="flex-grow pt-[64px] md:pl-64 min-h-screen bg-background pb-xl w-full text-left">
{impersonationToken && (
<div className="bg-primary text-white px-md py-sm flex justify-between items-center shadow-md mx-md md:mx-xl mt-md md:mt-0 mb-sm rounded-lg animate-fade-in">
<div className="flex items-center gap-sm">
<span className="material-symbols-outlined text-[20px]">vpn_key</span>
<span className="font-bold text-[14px]">Impersonation Mode Active</span>
<span className="text-[13px] opacity-90 border-l border-white/30 pl-sm ml-sm">
You are currently viewing data for <strong>{user?.org_name || organizations.find(o => String(o.id) === String(user?.org_id))?.name || 'this organization'}</strong>.
</span>
</div>
<button
onClick={handleReturnToAdmin}
className="bg-white text-primary hover:bg-surface-container transition-colors px-3 py-1.5 rounded-md font-bold text-[12px] border-0 cursor-pointer shadow-sm flex items-center gap-xs shrink-0"
>
<span className="material-symbols-outlined text-[16px]">exit_to_app</span>
Return
</button>
</div>
)}
<div className="px-md py-sm md:px-xl md:py-md max-w-container-max mx-auto flex flex-col gap-gutter">
{children}
</div>
</main>
</div>
);
};