mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-10 08:55:44 +00:00
first commit
This commit is contained in:
306
frontend/src/components/DashboardSettingsModal.jsx
Normal file
306
frontend/src/components/DashboardSettingsModal.jsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import {
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
X,
|
||||
GripVertical,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Settings as SettingsIcon
|
||||
} from 'lucide-react';
|
||||
import { dashboardPreferencesAPI } from '../utils/api';
|
||||
|
||||
// Sortable Card Item Component
|
||||
const SortableCardItem = ({ card, onToggle }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: card.cardId });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center justify-between p-3 bg-white border border-secondary-200 rounded-lg ${
|
||||
isDragging ? 'shadow-lg' : 'shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="text-secondary-400 hover:text-secondary-600 cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-secondary-900">
|
||||
{card.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onToggle(card.cardId)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||
card.enabled
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
|
||||
}`}
|
||||
>
|
||||
{card.enabled ? (
|
||||
<>
|
||||
<Eye className="h-3 w-3" />
|
||||
Visible
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="h-3 w-3" />
|
||||
Hidden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
const [cards, setCards] = useState([]);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch user's dashboard preferences
|
||||
const { data: preferences, isLoading } = useQuery({
|
||||
queryKey: ['dashboardPreferences'],
|
||||
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
|
||||
enabled: isOpen
|
||||
});
|
||||
|
||||
// Fetch default card configuration
|
||||
const { data: defaultCards } = useQuery({
|
||||
queryKey: ['dashboardDefaultCards'],
|
||||
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
|
||||
enabled: isOpen
|
||||
});
|
||||
|
||||
// Update preferences mutation
|
||||
const updatePreferencesMutation = useMutation({
|
||||
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
|
||||
onSuccess: (response) => {
|
||||
// Optimistically update the query cache with the correct data structure
|
||||
queryClient.setQueryData(['dashboardPreferences'], response.data.preferences);
|
||||
// Also invalidate to ensure fresh data
|
||||
queryClient.invalidateQueries(['dashboardPreferences']);
|
||||
setHasChanges(false);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update dashboard preferences:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize cards when preferences or defaults are loaded
|
||||
useEffect(() => {
|
||||
if (preferences && defaultCards) {
|
||||
// Merge user preferences with default cards
|
||||
const mergedCards = defaultCards.map(defaultCard => {
|
||||
const userPreference = preferences.find(p => p.cardId === defaultCard.cardId);
|
||||
return {
|
||||
...defaultCard,
|
||||
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
|
||||
order: userPreference ? userPreference.order : defaultCard.order
|
||||
};
|
||||
}).sort((a, b) => a.order - b.order);
|
||||
|
||||
setCards(mergedCards);
|
||||
}
|
||||
}, [preferences, defaultCards]);
|
||||
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active.id !== over.id) {
|
||||
setCards((items) => {
|
||||
const oldIndex = items.findIndex(item => item.cardId === active.id);
|
||||
const newIndex = items.findIndex(item => item.cardId === over.id);
|
||||
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
// Update order values
|
||||
return newItems.map((item, index) => ({
|
||||
...item,
|
||||
order: index
|
||||
}));
|
||||
});
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (cardId) => {
|
||||
setCards(prevCards =>
|
||||
prevCards.map(card =>
|
||||
card.cardId === cardId
|
||||
? { ...card, enabled: !card.enabled }
|
||||
: card
|
||||
)
|
||||
);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const preferences = cards.map(card => ({
|
||||
cardId: card.cardId,
|
||||
enabled: card.enabled,
|
||||
order: card.order
|
||||
}));
|
||||
|
||||
updatePreferencesMutation.mutate(preferences);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (defaultCards) {
|
||||
const resetCards = defaultCards.map(card => ({
|
||||
...card,
|
||||
enabled: true,
|
||||
order: card.order
|
||||
}));
|
||||
setCards(resetCards);
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="h-5 w-5 text-primary-600" />
|
||||
<h3 className="text-lg font-medium text-secondary-900">
|
||||
Dashboard Settings
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-secondary-600 mb-6">
|
||||
Customize your dashboard by reordering cards and toggling their visibility.
|
||||
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={cards.map(card => card.cardId)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{cards.map((card) => (
|
||||
<SortableCardItem
|
||||
key={card.cardId}
|
||||
card={card}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updatePreferencesMutation.isPending}
|
||||
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
|
||||
!hasChanges || updatePreferencesMutation.isPending
|
||||
? 'bg-secondary-400 cursor-not-allowed'
|
||||
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
|
||||
}`}
|
||||
>
|
||||
{updatePreferencesMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Reset to Defaults
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSettingsModal;
|
||||
439
frontend/src/components/Layout.jsx
Normal file
439
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import React from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Home,
|
||||
Server,
|
||||
Package,
|
||||
Shield,
|
||||
BarChart3,
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
User,
|
||||
Users,
|
||||
Settings,
|
||||
UserCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
Wrench
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { dashboardAPI, formatRelativeTime } from '../utils/api'
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
// Load sidebar state from localStorage, default to false
|
||||
const saved = localStorage.getItem('sidebarCollapsed')
|
||||
return saved ? JSON.parse(saved) : false
|
||||
})
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const { user, logout, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canManageSettings } = useAuth()
|
||||
const userMenuRef = useRef(null)
|
||||
|
||||
// Fetch dashboard stats for the "Last updated" info
|
||||
const { data: stats, refetch } = useQuery({
|
||||
queryKey: ['dashboardStats'],
|
||||
queryFn: () => dashboardAPI.getStats().then(res => res.data),
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
staleTime: 30000, // Consider data stale after 30 seconds
|
||||
})
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: Home },
|
||||
{
|
||||
section: 'Inventory',
|
||||
items: [
|
||||
...(canViewHosts() ? [{ name: 'Hosts', href: '/hosts', icon: Server }] : []),
|
||||
...(canManageHosts() ? [{ name: 'Host Groups', href: '/host-groups', icon: Users }] : []),
|
||||
...(canViewPackages() ? [{ name: 'Packages', href: '/packages', icon: Package }] : []),
|
||||
...(canViewHosts() ? [{ name: 'Repos', href: '/repositories', icon: GitBranch }] : []),
|
||||
{ name: 'Services', href: '/services', icon: Wrench, comingSoon: true },
|
||||
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true },
|
||||
]
|
||||
},
|
||||
{
|
||||
section: 'Users',
|
||||
items: [
|
||||
...(canViewUsers() ? [{ name: 'Users', href: '/users', icon: Users }] : []),
|
||||
...(canManageSettings() ? [{ name: 'Permissions', href: '/permissions', icon: Shield }] : []),
|
||||
]
|
||||
},
|
||||
{
|
||||
section: 'Settings',
|
||||
items: [
|
||||
...(canManageSettings() ? [{ name: 'Settings', href: '/settings', icon: Settings }] : []),
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const isActive = (path) => location.pathname === path
|
||||
|
||||
// Get page title based on current route
|
||||
const getPageTitle = () => {
|
||||
const path = location.pathname
|
||||
|
||||
if (path === '/') return 'Dashboard'
|
||||
if (path === '/hosts') return 'Hosts'
|
||||
if (path === '/host-groups') return 'Host Groups'
|
||||
if (path === '/packages') return 'Packages'
|
||||
if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories'
|
||||
if (path === '/services') return 'Services'
|
||||
if (path === '/users') return 'Users'
|
||||
if (path === '/permissions') return 'Permissions'
|
||||
if (path === '/settings') return 'Settings'
|
||||
if (path === '/profile') return 'My Profile'
|
||||
if (path.startsWith('/hosts/')) return 'Host Details'
|
||||
if (path.startsWith('/packages/')) return 'Package Details'
|
||||
|
||||
return 'PatchMon'
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
|
||||
// Save sidebar collapsed state to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed))
|
||||
}, [sidebarCollapsed])
|
||||
|
||||
// Close user menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(event.target)) {
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-50">
|
||||
{/* Mobile sidebar */}
|
||||
<div className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? 'block' : 'hidden'}`}>
|
||||
<div className="fixed inset-0 bg-secondary-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
|
||||
<div className="relative flex w-full max-w-xs flex-col bg-white pb-4 pt-5 shadow-xl">
|
||||
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<X className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center px-4">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-8 w-8 text-primary-600" />
|
||||
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">PatchMon</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mt-8 flex-1 space-y-6 px-2">
|
||||
{navigation.map((item, index) => {
|
||||
if (item.name) {
|
||||
// Single item (Dashboard)
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary-100 text-primary-900'
|
||||
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
|
||||
}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon className="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
} else if (item.section) {
|
||||
// Section with items
|
||||
return (
|
||||
<div key={item.section}>
|
||||
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2 px-2">
|
||||
{item.section}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{item.items.map((subItem) => (
|
||||
<Link
|
||||
key={subItem.name}
|
||||
to={subItem.href}
|
||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
||||
isActive(subItem.href)
|
||||
? 'bg-primary-100 text-primary-900'
|
||||
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
|
||||
} ${subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={subItem.comingSoon ? (e) => e.preventDefault() : () => setSidebarOpen(false)}
|
||||
>
|
||||
<subItem.icon className="mr-3 h-5 w-5" />
|
||||
<span className="flex items-center gap-2">
|
||||
{subItem.name}
|
||||
{subItem.comingSoon && (
|
||||
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'lg:w-16' : 'lg:w-64'
|
||||
} bg-white dark:bg-secondary-800`}>
|
||||
<div className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
|
||||
sidebarCollapsed ? 'px-2 shadow-lg' : 'px-6'
|
||||
}`}>
|
||||
<div className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
|
||||
sidebarCollapsed ? 'justify-center' : 'justify-between'
|
||||
}`}>
|
||||
{sidebarCollapsed ? (
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-8 w-8 text-primary-600" />
|
||||
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">PatchMon</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul className="flex flex-1 flex-col gap-y-6">
|
||||
{navigation.map((item, index) => {
|
||||
if (item.name) {
|
||||
// Single item (Dashboard)
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
to={item.href}
|
||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'}`}
|
||||
title={sidebarCollapsed ? item.name : ''}
|
||||
>
|
||||
<item.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="truncate">{item.name}</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
} else if (item.section) {
|
||||
// Section with items
|
||||
return (
|
||||
<li key={item.section}>
|
||||
{!sidebarCollapsed && (
|
||||
<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2 px-2">
|
||||
{item.section}
|
||||
</h3>
|
||||
)}
|
||||
<ul className={`space-y-1 ${sidebarCollapsed ? '' : '-mx-2'}`}>
|
||||
{item.items.map((subItem) => (
|
||||
<li key={subItem.name}>
|
||||
<Link
|
||||
to={subItem.href}
|
||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 ${
|
||||
isActive(subItem.href)
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'} ${
|
||||
subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
title={sidebarCollapsed ? subItem.name : ''}
|
||||
onClick={subItem.comingSoon ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{subItem.name}
|
||||
{subItem.comingSoon && (
|
||||
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Profile Section - Bottom of Sidebar */}
|
||||
<div className="border-t border-secondary-200">
|
||||
{!sidebarCollapsed ? (
|
||||
<div className="space-y-1">
|
||||
{/* My Profile Link */}
|
||||
<Link
|
||||
to="/profile"
|
||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 p-2 ${
|
||||
isActive('/profile')
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-500'
|
||||
}`}
|
||||
>
|
||||
<UserCircle className="h-5 w-5 shrink-0" />
|
||||
<span className="truncate">My Profile</span>
|
||||
</Link>
|
||||
|
||||
{/* User Info with Sign Out */}
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{user?.username}
|
||||
</p>
|
||||
{user?.role === 'admin' && (
|
||||
<span className="inline-flex items-center rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-800">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 truncate">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="ml-2 p-1.5 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center justify-center p-2 text-secondary-700 hover:bg-secondary-50 rounded-md transition-colors"
|
||||
title="My Profile"
|
||||
>
|
||||
<UserCircle className="h-5 w-5 text-white" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center justify-center w-full p-2 text-secondary-700 hover:bg-secondary-50 rounded-md transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className={`transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-64'
|
||||
}`}>
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-secondary-700 lg:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="h-6 w-px bg-secondary-200 lg:hidden" />
|
||||
|
||||
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
||||
<div className="relative flex flex-1 items-center">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
{getPageTitle()}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
||||
|
||||
{/* Last updated info */}
|
||||
{stats && (
|
||||
<div className="flex items-center gap-x-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Last updated: {formatRelativeTime(stats.lastUpdated)}</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-800 rounded"
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customize Dashboard Button - Only show on Dashboard page */}
|
||||
{location.pathname === '/' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// This will be handled by the Dashboard component
|
||||
const event = new CustomEvent('openDashboardSettings');
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Customize Dashboard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="py-6 bg-secondary-50 dark:bg-secondary-800 min-h-screen">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
47
frontend/src/components/ProtectedRoute.jsx
Normal file
47
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => {
|
||||
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated()) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
// Check admin requirement
|
||||
if (requireAdmin && !isAdmin()) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
|
||||
<p className="text-secondary-600">You don't have permission to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check specific permission requirement
|
||||
if (requirePermission && !hasPermission(requirePermission)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
|
||||
<p className="text-secondary-600">You don't have permission to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default ProtectedRoute
|
||||
Reference in New Issue
Block a user