Created toggle for enable / disable user signup flow with user role

Fixed numbers mismatching in host cards
Fixed issues with the settings file
Fixed layouts on hosts/packages/repos
Added ability to delete multiple hosts at once
Fixed Dark mode styling in areas
Removed console debugging messages
Done some other stuff ...
This commit is contained in:
Muhammad Ibrahim
2025-09-22 01:01:50 +01:00
parent a268f6b8f1
commit 797be20c45
29 changed files with 940 additions and 4015 deletions

View File

@@ -24,12 +24,8 @@ function AppRoutes() {
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth()
const isAuth = isAuthenticated() // Call the function to get boolean value
// Debug logging
console.log('AppRoutes state:', { needsFirstTimeSetup, checkingSetup, isAuthenticated: isAuth })
// Show loading while checking if setup is needed
if (checkingSetup) {
console.log('Showing loading screen...')
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
<div className="text-center">
@@ -42,12 +38,9 @@ function AppRoutes() {
// Show first-time setup if no admin users exist
if (needsFirstTimeSetup && !isAuth) {
console.log('Showing FirstTimeAdminSetup component...')
return <FirstTimeAdminSetup />
}
console.log('Showing normal routes (Login/Dashboard)...')
return (
<Routes>
<Route path="/login" element={<Login />} />

View File

@@ -28,9 +28,11 @@ import {
Settings as SettingsIcon
} from 'lucide-react';
import { dashboardPreferencesAPI } from '../utils/api';
import { useTheme } from '../contexts/ThemeContext';
// Sortable Card Item Component
const SortableCardItem = ({ card, onToggle }) => {
const { isDark } = useTheme();
const {
attributes,
listeners,
@@ -50,7 +52,7 @@ const SortableCardItem = ({ card, onToggle }) => {
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-3 bg-white border border-secondary-200 rounded-lg ${
className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${
isDragging ? 'shadow-lg' : 'shadow-sm'
}`}
>
@@ -58,12 +60,12 @@ const SortableCardItem = ({ card, onToggle }) => {
<button
{...attributes}
{...listeners}
className="text-secondary-400 hover:text-secondary-600 cursor-grab active:cursor-grabbing"
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 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">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{card.title}
</div>
</div>
@@ -73,8 +75,8 @@ const SortableCardItem = ({ card, onToggle }) => {
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'
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800'
: 'bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
}`}
>
{card.enabled ? (
@@ -97,6 +99,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const { isDark } = useTheme();
const sensors = useSensors(
useSensor(PointerSensor),
@@ -212,24 +215,24 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
<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="inline-block align-bottom bg-white dark:bg-secondary-800 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 dark:bg-secondary-800 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">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Dashboard Settings
</h3>
</div>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600"
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-secondary-600 mb-6">
<p className="text-sm text-secondary-600 dark:text-secondary-400 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>
@@ -259,7 +262,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
)}
</div>
<div className="bg-secondary-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending}
@@ -284,7 +287,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
<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"
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
@@ -292,7 +295,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
<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"
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>

View File

@@ -30,15 +30,19 @@ const FirstTimeAdminSetup = () => {
return false
}
if (!formData.email.trim()) {
setError('Email is required')
setError('Email address is required')
return false
}
if (!formData.email.includes('@')) {
setError('Please enter a valid email address')
// Enhanced email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(formData.email.trim())) {
setError('Please enter a valid email address (e.g., user@example.com)')
return false
}
if (formData.password.length < 6) {
setError('Password must be at least 6 characters')
if (formData.password.length < 8) {
setError('Password must be at least 8 characters for security')
return false
}
if (formData.password !== formData.confirmPassword) {
@@ -186,7 +190,7 @@ const FirstTimeAdminSetup = () => {
value={formData.password}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your password (min 6 characters)"
placeholder="Enter your password (min 8 characters)"
required
disabled={isLoading}
/>

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Edit2, Check, X, ChevronDown } from 'lucide-react';
const InlineGroupEdit = ({
@@ -88,11 +88,8 @@ const InlineGroupEdit = ({
const handleSave = async () => {
if (disabled || isLoading) return;
console.log('handleSave called:', { selectedValue, originalValue: value, changed: selectedValue !== value });
// Check if value actually changed
if (selectedValue === value) {
console.log('No change detected, closing edit mode');
setIsEditing(false);
setIsOpen(false);
return;
@@ -102,15 +99,12 @@ const InlineGroupEdit = ({
setError('');
try {
console.log('Calling onSave with:', selectedValue);
await onSave(selectedValue);
console.log('Save successful');
// Update the local value to match the saved value
setSelectedValue(selectedValue);
setIsEditing(false);
setIsOpen(false);
} catch (err) {
console.error('Save failed:', err);
setError(err.message || 'Failed to save');
} finally {
setIsLoading(false);
@@ -127,22 +121,23 @@ const InlineGroupEdit = ({
}
};
const getDisplayValue = () => {
console.log('getDisplayValue called with:', { value, options });
const displayValue = useMemo(() => {
if (!value) {
console.log('No value, returning Ungrouped');
return 'Ungrouped';
}
const option = options.find(opt => opt.id === value);
console.log('Found option:', option);
return option ? option.name : 'Unknown Group';
};
}, [value, options]);
const getDisplayColor = () => {
const displayColor = useMemo(() => {
if (!value) return 'bg-secondary-100 text-secondary-800';
const option = options.find(opt => opt.id === value);
return option ? `text-white` : 'bg-secondary-100 text-secondary-800';
};
}, [value, options]);
const selectedOption = useMemo(() => {
return options.find(opt => opt.id === value);
}, [value, options]);
if (isEditing) {
return (
@@ -241,10 +236,10 @@ const InlineGroupEdit = ({
return (
<div className={`flex items-center gap-2 group ${className}`}>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getDisplayColor()}`}
style={value ? { backgroundColor: options.find(opt => opt.id === value)?.color } : {}}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${displayColor}`}
style={value ? { backgroundColor: selectedOption?.color } : {}}
>
{getDisplayValue()}
{displayValue}
</span>
{!disabled && (
<button

View File

@@ -23,7 +23,12 @@ import {
Plus,
Activity,
Cog,
FileText
FileText,
Github,
MessageCircle,
Mail,
Star,
Globe
} from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
@@ -40,6 +45,7 @@ const Layout = ({ children }) => {
return saved ? JSON.parse(saved) : false
})
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [githubStars, setGithubStars] = useState(null)
const location = useLocation()
const { user, logout, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canManageSettings } = useAuth()
const { updateAvailable } = useUpdateNotification()
@@ -133,10 +139,56 @@ const Layout = ({ children }) => {
window.location.href = '/hosts?action=add'
}
const copyEmailToClipboard = async () => {
const email = 'support@patchmon.net'
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(email)
} else {
// Fallback for non-secure contexts
const textArea = document.createElement('textarea')
textArea.value = email
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
document.execCommand('copy')
textArea.remove()
}
// You could add a toast notification here if you have one
} catch (err) {
console.error('Failed to copy email:', err)
// Fallback: show email in prompt
prompt('Copy this email address:', email)
}
}
// Fetch GitHub stars count
const fetchGitHubStars = async () => {
try {
const response = await fetch('https://api.github.com/repos/9technologygroup/patchmon.net')
if (response.ok) {
const data = await response.json()
setGithubStars(data.stargazers_count)
}
} catch (error) {
console.error('Failed to fetch GitHub stars:', error)
}
}
// Short format for navigation area
const formatRelativeTimeShort = (date) => {
if (!date) return 'Never'
const now = new Date()
const diff = now - new Date(date)
const dateObj = new Date(date)
// Check if date is valid
if (isNaN(dateObj.getTime())) return 'Invalid date'
const diff = now - dateObj
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
@@ -167,6 +219,11 @@ const Layout = ({ children }) => {
}
}, [])
// Fetch GitHub stars on component mount
useEffect(() => {
fetchGitHubStars()
}, [])
return (
<div className="min-h-screen bg-secondary-50">
{/* Mobile sidebar */}
@@ -425,6 +482,7 @@ const Layout = ({ children }) => {
</ul>
</nav>
{/* Profile Section - Bottom of Sidebar */}
<div className="border-t border-secondary-200 dark:border-secondary-600">
{!sidebarCollapsed ? (
@@ -560,20 +618,54 @@ const Layout = ({ children }) => {
</h2>
</div>
<div className="flex items-center gap-x-4 lg:gap-x-6">
{/* 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"
{/* External Links */}
<div className="flex items-center gap-2">
<a
href="https://github.com/9technologygroup/patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm group relative"
title="⭐ Star us on GitHub! Click to open repository"
>
<Settings className="h-4 w-4" />
Customize Dashboard
<Github className="h-5 w-5 flex-shrink-0" />
{githubStars !== null && (
<div className="flex items-center gap-0.5">
<Star className="h-3 w-3 fill-current text-yellow-500" />
<span className="text-sm font-medium">{githubStars}</span>
</div>
)}
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-sm rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-50">
Star us on GitHub!
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-100"></div>
</div>
</a>
<a
href="https://discord.gg/DDKQeW6mnq"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Discord"
>
<MessageCircle className="h-5 w-5" />
</a>
<button
onClick={copyEmailToClipboard}
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Copy support@patchmon.net"
>
<Mail className="h-5 w-5" />
</button>
)}
<a
href="https://patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Visit patchmon.net"
>
<Globe className="h-5 w-5" />
</a>
</div>
</div>
</div>
</div>

View File

@@ -18,10 +18,6 @@ export const AuthProvider = ({ children }) => {
const [permissionsLoading, setPermissionsLoading] = useState(false)
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false)
// Debug: Log when needsFirstTimeSetup changes
useEffect(() => {
console.log('needsFirstTimeSetup changed to:', needsFirstTimeSetup)
}, [needsFirstTimeSetup])
const [checkingSetup, setCheckingSetup] = useState(true)
// Initialize auth state from localStorage
@@ -227,21 +223,17 @@ export const AuthProvider = ({ children }) => {
// Check if any admin users exist (for first-time setup)
const checkAdminUsersExist = useCallback(async () => {
try {
console.log('Making API call to check admin users...')
const response = await fetch('/api/v1/auth/check-admin-users', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (response.ok) {
const data = await response.json()
console.log('Admin check response:', data) // Debug log
console.log('hasAdminUsers:', data.hasAdminUsers, 'Setting needsFirstTimeSetup to:', !data.hasAdminUsers)
setNeedsFirstTimeSetup(!data.hasAdminUsers)
} else {
console.log('Admin check failed:', response.status, response.statusText) // Debug log
// If endpoint doesn't exist or fails, assume setup is needed
setNeedsFirstTimeSetup(true)
}
@@ -256,12 +248,9 @@ export const AuthProvider = ({ children }) => {
// Check for admin users on initial load
useEffect(() => {
console.log('AuthContext useEffect triggered:', { token: !!token, user: !!user })
if (!token && !user) {
console.log('Calling checkAdminUsersExist...')
checkAdminUsersExist()
} else {
console.log('Skipping admin check - user already authenticated')
setCheckingSetup(false)
}
}, [token, user, checkAdminUsersExist])

View File

@@ -9,13 +9,15 @@ import {
TrendingUp,
RefreshCw,
Clock,
WifiOff
WifiOff,
Settings
} from 'lucide-react'
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
import { Pie, Bar } from 'react-chartjs-2'
import { dashboardAPI, dashboardPreferencesAPI, settingsAPI, formatRelativeTime } from '../utils/api'
import DashboardSettingsModal from '../components/DashboardSettingsModal'
import { useTheme } from '../contexts/ThemeContext'
import { useAuth } from '../contexts/AuthContext'
// Register Chart.js components
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title)
@@ -25,6 +27,7 @@ const Dashboard = () => {
const [cardPreferences, setCardPreferences] = useState([])
const navigate = useNavigate()
const { isDark } = useTheme()
const { user } = useAuth()
// Navigation handlers
const handleTotalHostsClick = () => {
@@ -52,17 +55,61 @@ const Dashboard = () => {
}
const handleOSDistributionClick = () => {
navigate('/hosts', { replace: true })
navigate('/hosts?showFilters=true', { replace: true })
}
const handleUpdateStatusClick = () => {
navigate('/hosts', { replace: true })
navigate('/hosts?filter=needsUpdates', { replace: true })
}
const handlePackagePriorityClick = () => {
navigate('/packages?filter=security')
}
// Chart click handlers
const handleOSChartClick = (event, elements) => {
if (elements.length > 0) {
const elementIndex = elements[0].index
const osName = stats.charts.osDistribution[elementIndex].name.toLowerCase()
navigate(`/hosts?osFilter=${osName}&showFilters=true`, { replace: true })
}
}
const handleUpdateStatusChartClick = (event, elements) => {
if (elements.length > 0) {
const elementIndex = elements[0].index
const statusName = stats.charts.updateStatusDistribution[elementIndex].name
// Map status names to filter parameters
let filter = ''
if (statusName.toLowerCase().includes('needs updates')) {
filter = 'needsUpdates'
} else if (statusName.toLowerCase().includes('up to date')) {
filter = 'upToDate'
} else if (statusName.toLowerCase().includes('stale')) {
filter = 'stale'
}
if (filter) {
navigate(`/hosts?filter=${filter}`, { replace: true })
}
}
}
const handlePackagePriorityChartClick = (event, elements) => {
if (elements.length > 0) {
const elementIndex = elements[0].index
const priorityName = stats.charts.packageUpdateDistribution[elementIndex].name
// Map priority names to filter parameters
if (priorityName.toLowerCase().includes('security')) {
navigate('/packages?filter=security', { replace: true })
} else if (priorityName.toLowerCase().includes('outdated')) {
navigate('/packages?filter=outdated', { replace: true })
}
}
}
// Helper function to format the update interval threshold
const formatUpdateIntervalThreshold = () => {
if (!settings?.updateInterval) return '24 hours'
@@ -373,7 +420,7 @@ const Dashboard = () => {
>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Update Status</h3>
<div className="h-64">
<Pie data={updateStatusChartData} options={chartOptions} />
<Pie data={updateStatusChartData} options={updateStatusChartOptions} />
</div>
</div>
);
@@ -386,7 +433,7 @@ const Dashboard = () => {
>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Package Priority</h3>
<div className="h-64">
<Pie data={packagePriorityChartData} options={chartOptions} />
<Pie data={packagePriorityChartData} options={packagePriorityChartOptions} />
</div>
</div>
);
@@ -469,6 +516,39 @@ const Dashboard = () => {
}
},
},
onClick: handleOSChartClick,
}
const updateStatusChartOptions = {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: isDark ? '#ffffff' : '#374151',
font: {
size: 12
}
}
},
},
onClick: handleUpdateStatusChartClick,
}
const packagePriorityChartOptions = {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: isDark ? '#ffffff' : '#374151',
font: {
size: 12
}
}
},
},
onClick: handlePackagePriorityChartClick,
}
const barChartOptions = {
@@ -582,12 +662,22 @@ const Dashboard = () => {
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Dashboard</h1>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
Welcome back, {user?.first_name || user?.username || 'User'} 👋
</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Overview of your PatchMon infrastructure
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowSettingsModal(true)}
className="btn-outline flex items-center gap-2"
title="Customize dashboard layout"
>
<Settings className="h-4 w-4" />
Customize Dashboard
</button>
<button
onClick={() => refetch()}
disabled={isFetching}

View File

@@ -211,9 +211,9 @@ const HostDetail = () => {
<span className="text-xs font-medium">Last updated:</span>
<span>{formatRelativeTime(host.last_update)}</span>
</div>
<div className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdatedPackages > 0)}`}>
{getStatusIcon(isStale, host.stats.outdatedPackages > 0)}
{getStatusText(isStale, host.stats.outdatedPackages > 0)}
<div className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdated_packages > 0)}`}>
{getStatusIcon(isStale, host.stats.outdated_packages > 0)}
{getStatusText(isStale, host.stats.outdated_packages > 0)}
</div>
</div>
<div className="flex items-center gap-2">
@@ -333,12 +333,12 @@ const HostDetail = () => {
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Host Group</p>
{host.hostGroup ? (
{host.host_groups ? (
<span
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: host.hostGroup.color }}
style={{ backgroundColor: host.host_groups.color }}
>
{host.hostGroup.name}
{host.host_groups.name}
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-200">
@@ -355,18 +355,6 @@ const HostDetail = () => {
</div>
</div>
{host.ip && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">IP Address</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.ip}</p>
</div>
)}
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Last Update</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{formatRelativeTime(host.last_update)}</p>
</div>
{host.agent_version && (
<div className="flex items-center justify-between">
@@ -720,7 +708,7 @@ const HostDetail = () => {
{/* Package Statistics */}
<div className="card">
<div className="px-4 py-2.5 border-b border-secondary-200 dark:border-secondary-600">
<h3 className="text-sm font-medium text-secondary-900 dark:text-white">Package Statistics</h3>
<h3 className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Package Statistics</h3>
</div>
<div className="p-4">
<div className="grid grid-cols-3 gap-4">
@@ -728,7 +716,7 @@ const HostDetail = () => {
<div className="flex items-center justify-center w-12 h-12 bg-primary-100 dark:bg-primary-800 rounded-lg mx-auto mb-2">
<Package className="h-6 w-6 text-primary-600 dark:text-primary-400" />
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.totalPackages}</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.total_packages}</p>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Total Packages</p>
</div>
@@ -740,7 +728,7 @@ const HostDetail = () => {
<div className="flex items-center justify-center w-12 h-12 bg-warning-100 dark:bg-warning-800 rounded-lg mx-auto mb-2 group-hover:bg-warning-200 dark:group-hover:bg-warning-700 transition-colors">
<Clock className="h-6 w-6 text-warning-600 dark:text-warning-400" />
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.outdatedPackages}</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.outdated_packages}</p>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Outdated Packages</p>
</button>
@@ -752,7 +740,7 @@ const HostDetail = () => {
<div className="flex items-center justify-center w-12 h-12 bg-danger-100 dark:bg-danger-800 rounded-lg mx-auto mb-2 group-hover:bg-danger-200 dark:group-hover:bg-danger-700 transition-colors">
<Shield className="h-6 w-6 text-danger-600 dark:text-danger-400" />
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.securityUpdates}</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.security_updates}</p>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Security Updates</p>
</button>
</div>
@@ -797,8 +785,40 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
const serverUrl = serverUrlData?.server_url || 'http://localhost:3001'
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text)
const copyToClipboard = async (text) => {
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return
}
// Fallback for older browsers or non-secure contexts
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
if (!successful) {
throw new Error('Copy command failed')
}
} catch (err) {
// If all else fails, show the text in a prompt
prompt('Copy this command:', text)
} finally {
document.body.removeChild(textArea)
}
} catch (err) {
console.error('Failed to copy to clipboard:', err)
// Show the text in a prompt as last resort
prompt('Copy this command:', text)
}
}
const getSetupCommands = () => {
@@ -1035,12 +1055,12 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="flex items-center gap-2">
<input
type="text"
value={host.apiId}
value={host.api_id}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard(host.apiId)}
onClick={() => copyToClipboard(host.api_id)}
className="btn-outline flex items-center gap-1"
>
<Copy className="h-4 w-4" />
@@ -1054,7 +1074,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="flex items-center gap-2">
<input
type={showApiKey ? 'text' : 'password'}
value={host.apiKey}
value={host.api_key}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
@@ -1065,7 +1085,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
<button
onClick={() => copyToClipboard(host.apiKey)}
onClick={() => copyToClipboard(host.api_key)}
className="btn-outline flex items-center gap-1"
>
<Copy className="h-4 w-4" />

View File

@@ -121,16 +121,16 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
{/* No Group Option */}
<button
type="button"
onClick={() => setFormData({ ...formData, hostGroupId: '' })}
onClick={() => setFormData({ ...formData, host_group_id: '' })}
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
formData.hostGroupId === ''
formData.host_group_id === ''
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
: 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
}`}
>
<div className="text-xs font-medium">No Group</div>
<div className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">Ungrouped</div>
{formData.hostGroupId === '' && (
{formData.host_group_id === '' && (
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
</div>
@@ -142,9 +142,9 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
<button
key={group.id}
type="button"
onClick={() => setFormData({ ...formData, hostGroupId: group.id })}
onClick={() => setFormData({ ...formData, host_group_id: group.id })}
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
formData.hostGroupId === group.id
formData.host_group_id === group.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
: 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
}`}
@@ -159,7 +159,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
<div className="text-xs font-medium truncate max-w-full">{group.name}</div>
</div>
<div className="text-xs text-secondary-500 dark:text-secondary-400">Group</div>
{formData.hostGroupId === group.id && (
{formData.host_group_id === group.id && (
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
</div>
@@ -214,9 +214,43 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
}
}, [host?.isNewHost])
const copyToClipboard = (text, label) => {
navigator.clipboard.writeText(text)
alert(`${label} copied to clipboard!`)
const copyToClipboard = async (text, label) => {
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
alert(`${label} copied to clipboard!`)
return
}
// Fallback for older browsers or non-secure contexts
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
alert(`${label} copied to clipboard!`)
} else {
throw new Error('Copy command failed')
}
} catch (err) {
// If all else fails, show the text in a prompt
prompt(`Copy this ${label.toLowerCase()}:`, text)
} finally {
document.body.removeChild(textArea)
}
} catch (err) {
console.error('Failed to copy to clipboard:', err)
// Show the text in a prompt as last resort
prompt(`Copy this ${label.toLowerCase()}:`, text)
}
}
// Fetch server URL from settings
@@ -604,6 +638,7 @@ const Hosts = () => {
const [showAddModal, setShowAddModal] = useState(false)
const [selectedHosts, setSelectedHosts] = useState([])
const [showBulkAssignModal, setShowBulkAssignModal] = useState(false)
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
const [searchParams] = useSearchParams()
const navigate = useNavigate()
@@ -622,6 +657,9 @@ const Hosts = () => {
// Handle URL filter parameters
useEffect(() => {
const filter = searchParams.get('filter')
const showFiltersParam = searchParams.get('showFilters')
const osFilterParam = searchParams.get('osFilter')
if (filter === 'needsUpdates') {
setShowFilters(true)
setStatusFilter('all')
@@ -634,6 +672,18 @@ const Hosts = () => {
setShowFilters(true)
setStatusFilter('active')
// We'll filter hosts that are up to date in the filtering logic
} else if (filter === 'stale') {
setShowFilters(true)
setStatusFilter('all')
// We'll filter hosts that are stale in the filtering logic
} else if (showFiltersParam === 'true') {
setShowFilters(true)
}
// Handle OS filter parameter
if (osFilterParam) {
setShowFilters(true)
setOsFilter(osFilterParam)
}
// Handle add host action from navigation
@@ -732,7 +782,7 @@ const Hosts = () => {
// Ensure hostGroupId is set correctly
return {
...updatedHost,
hostGroupId: updatedHost.hostGroup?.id || null
hostGroupId: updatedHost.host_groups?.id || null
};
}
return host;
@@ -771,9 +821,6 @@ const Hosts = () => {
});
},
onSuccess: (data) => {
console.log('updateHostGroupMutation success:', data);
console.log('Updated host data:', data.host);
console.log('Host group in response:', data.host.hostGroup);
// Update the cache with the new host data
queryClient.setQueryData(['hosts'], (oldData) => {
@@ -785,7 +832,7 @@ const Hosts = () => {
// Ensure hostGroupId is set correctly
const updatedHost = {
...data.host,
hostGroupId: data.host.hostGroup?.id || null
hostGroupId: data.host.host_groups?.id || null
};
console.log('Updated host with hostGroupId:', updatedHost);
return updatedHost;
@@ -804,6 +851,19 @@ const Hosts = () => {
}
})
const bulkDeleteMutation = useMutation({
mutationFn: (hostIds) => adminHostsAPI.deleteBulk(hostIds),
onSuccess: (data) => {
console.log('Bulk delete success:', data);
queryClient.invalidateQueries(['hosts']);
setSelectedHosts([]);
setShowBulkDeleteModal(false);
},
onError: (error) => {
console.error('Bulk delete error:', error);
}
});
// Helper functions for bulk selection
const handleSelectHost = (hostId) => {
setSelectedHosts(prev =>
@@ -825,6 +885,10 @@ const Hosts = () => {
bulkUpdateGroupMutation.mutate({ hostIds: selectedHosts, hostGroupId })
}
const handleBulkDelete = () => {
bulkDeleteMutation.mutate(selectedHosts)
}
// Table filtering and sorting logic
const filteredAndSortedHosts = React.useMemo(() => {
if (!hosts) return []
@@ -838,8 +902,8 @@ const Hosts = () => {
// Group filter
const matchesGroup = groupFilter === 'all' ||
(groupFilter === 'ungrouped' && !host.hostGroup) ||
(groupFilter !== 'ungrouped' && host.hostGroup?.id === groupFilter)
(groupFilter === 'ungrouped' && !host.host_groups) ||
(groupFilter !== 'ungrouped' && host.host_groups?.id === groupFilter)
// Status filter
const matchesStatus = statusFilter === 'all' || (host.effectiveStatus || host.status) === statusFilter
@@ -847,12 +911,13 @@ const Hosts = () => {
// OS filter
const matchesOs = osFilter === 'all' || host.os_type?.toLowerCase() === osFilter.toLowerCase()
// URL filter for hosts needing updates, inactive hosts, or up-to-date hosts
// URL filter for hosts needing updates, inactive hosts, up-to-date hosts, or stale hosts
const filter = searchParams.get('filter')
const matchesUrlFilter =
(filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0)) &&
(filter !== 'inactive' || (host.effectiveStatus || host.status) === 'inactive') &&
(filter !== 'upToDate' || (!host.isStale && host.updatesCount === 0))
(filter !== 'upToDate' || (!host.isStale && host.updatesCount === 0)) &&
(filter !== 'stale' || host.isStale)
// Hide stale filter
const matchesHideStale = !hideStale || !host.isStale
@@ -878,8 +943,8 @@ const Hosts = () => {
bValue = b.ip?.toLowerCase() || 'zzz_no_ip'
break
case 'group':
aValue = a.hostGroup?.name || 'zzz_ungrouped'
bValue = b.hostGroup?.name || 'zzz_ungrouped'
aValue = a.host_groups?.name || 'zzz_ungrouped'
bValue = b.host_groups?.name || 'zzz_ungrouped'
break
case 'os':
aValue = a.os_type?.toLowerCase() || 'zzz_unknown'
@@ -929,7 +994,7 @@ const Hosts = () => {
let groupKey
switch (groupBy) {
case 'group':
groupKey = host.hostGroup?.name || 'Ungrouped'
groupKey = host.host_groups?.name || 'Ungrouped'
break
case 'status':
groupKey = (host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)
@@ -1055,16 +1120,10 @@ const Hosts = () => {
</div>
)
case 'group':
console.log('Rendering group for host:', {
hostId: host.id,
hostGroupId: host.hostGroupId,
hostGroup: host.hostGroup,
availableGroups: hostGroups
});
return (
<InlineGroupEdit
key={`${host.id}-${host.hostGroup?.id || 'ungrouped'}-${host.hostGroup?.name || 'ungrouped'}`}
value={host.hostGroup?.id}
key={`${host.id}-${host.host_groups?.id || 'ungrouped'}-${host.host_groups?.name || 'ungrouped'}`}
value={host.host_groups?.id}
onSave={(newGroupId) => updateHostGroupMutation.mutate({ hostId: host.id, hostGroupId: newGroupId })}
options={hostGroups || []}
placeholder="Select group..."
@@ -1232,9 +1291,9 @@ const Hosts = () => {
}
return (
<div className="space-y-6">
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Page Header */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Hosts</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
@@ -1262,7 +1321,7 @@ const Hosts = () => {
</div>
{/* Stats Summary */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
<div
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleTotalHostsClick}
@@ -1320,8 +1379,8 @@ const Hosts = () => {
</div>
{/* Hosts List */}
<div className="card">
<div className="px-4 py-4 sm:p-4">
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
<div className="flex items-center justify-end mb-4">
{selectedHosts.length > 0 && (
<div className="flex items-center gap-3">
@@ -1335,6 +1394,13 @@ const Hosts = () => {
<Users className="h-4 w-4" />
Assign to Group
</button>
<button
onClick={() => setShowBulkDeleteModal(true)}
className="btn-danger flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
<button
onClick={() => setSelectedHosts([])}
className="text-sm text-secondary-500 hover:text-secondary-700"
@@ -1471,17 +1537,27 @@ const Hosts = () => {
)}
</div>
{(!hosts || hosts.length === 0) ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500">No hosts registered yet</p>
<p className="text-sm text-secondary-400 mt-2">
Click "Add Host" to manually register a new host and get API credentials
</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(groupedHosts).map(([groupName, groupHosts]) => (
<div className="flex-1 overflow-hidden">
{(!hosts || hosts.length === 0) ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500">No hosts registered yet</p>
<p className="text-sm text-secondary-400 mt-2">
Click "Add Host" to manually register a new host and get API credentials
</p>
</div>
) : filteredAndSortedHosts.length === 0 ? (
<div className="text-center py-8">
<Search className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500">No hosts match your current filters</p>
<p className="text-sm text-secondary-400 mt-2">
Try adjusting your search terms or filters to see more results
</p>
</div>
) : (
<div className="h-full overflow-auto">
<div className="space-y-6">
{Object.entries(groupedHosts).map(([groupName, groupHosts]) => (
<div key={groupName} className="space-y-3">
{/* Group Header */}
{groupBy !== 'none' && (
@@ -1628,11 +1704,13 @@ const Hosts = () => {
</table>
</div>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Modals */}
<AddHostModal
@@ -1653,6 +1731,17 @@ const Hosts = () => {
/>
)}
{/* Bulk Delete Modal */}
{showBulkDeleteModal && (
<BulkDeleteModal
selectedHosts={selectedHosts}
hosts={hosts}
onClose={() => setShowBulkDeleteModal(false)}
onDelete={handleBulkDelete}
isLoading={bulkDeleteMutation.isPending}
/>
)}
{/* Column Settings Modal */}
{showColumnSettings && (
<ColumnSettingsModal
@@ -1756,6 +1845,85 @@ const BulkAssignModal = ({ selectedHosts, hosts, onClose, onAssign, isLoading })
)
}
// Bulk Delete Modal Component
const BulkDeleteModal = ({ selectedHosts, hosts, onClose, onDelete, isLoading }) => {
const selectedHostNames = hosts
.filter(host => selectedHosts.includes(host.id))
.map(host => host.friendly_name || host.hostname || host.id)
const handleSubmit = (e) => {
e.preventDefault()
onDelete()
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Delete Hosts</h3>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
disabled={isLoading}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="px-6 py-4">
<div className="mb-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="h-5 w-5 text-danger-600" />
<h4 className="text-sm font-medium text-danger-800 dark:text-danger-200">
Warning: This action cannot be undone
</h4>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-4">
You are about to permanently delete {selectedHosts.length} host{selectedHosts.length !== 1 ? 's' : ''}.
This will remove all host data, including package information, update history, and API credentials.
</p>
</div>
<div className="mb-4">
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-2">
Hosts to be deleted:
</p>
<div className="max-h-32 overflow-y-auto bg-secondary-50 dark:bg-secondary-700 rounded-md p-3">
{selectedHostNames.map((friendlyName, index) => (
<div key={index} className="text-sm text-secondary-700 dark:text-secondary-300">
{friendlyName}
</div>
))}
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="btn-danger"
disabled={isLoading}
>
{isLoading ? 'Deleting...' : `Delete ${selectedHosts.length} Host${selectedHosts.length !== 1 ? 's' : ''}`}
</button>
</div>
</form>
</div>
</div>
</div>
)
}
// Column Settings Modal Component
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
const [draggedIndex, setDraggedIndex] = useState(null)

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft, Mail } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
@@ -19,10 +19,29 @@ const Login = () => {
const [error, setError] = useState('')
const [requiresTfa, setRequiresTfa] = useState(false)
const [tfaUsername, setTfaUsername] = useState('')
const [signupEnabled, setSignupEnabled] = useState(false)
const navigate = useNavigate()
const { login } = useAuth()
// Check if signup is enabled
useEffect(() => {
const checkSignupEnabled = async () => {
try {
const response = await fetch('/api/v1/auth/signup-enabled')
if (response.ok) {
const data = await response.json()
setSignupEnabled(data.signupEnabled)
}
} catch (error) {
console.error('Failed to check signup status:', error)
// Default to disabled on error for security
setSignupEnabled(false)
}
}
checkSignupEnabled()
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
@@ -135,6 +154,10 @@ const Login = () => {
}
const toggleMode = () => {
// Only allow signup mode if signup is enabled
if (!signupEnabled && !isSignupMode) {
return // Don't allow switching to signup if disabled
}
setIsSignupMode(!isSignupMode)
setFormData({
username: '',
@@ -269,18 +292,20 @@ const Login = () => {
</button>
</div>
<div className="text-center">
<p className="text-sm text-secondary-600">
{isSignupMode ? 'Already have an account?' : "Don't have an account?"}{' '}
<button
type="button"
onClick={toggleMode}
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
>
{isSignupMode ? 'Sign in' : 'Sign up'}
</button>
</p>
</div>
{signupEnabled && (
<div className="text-center">
<p className="text-sm text-secondary-600">
{isSignupMode ? 'Already have an account?' : "Don't have an account?"}{' '}
<button
type="button"
onClick={toggleMode}
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
>
{isSignupMode ? 'Sign in' : 'Sign up'}
</button>
</p>
</div>
)}
</form>
) : (
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>

View File

@@ -71,8 +71,9 @@ const Packages = () => {
// Handle affected hosts click
const handleAffectedHostsClick = (pkg) => {
const hostIds = pkg.affectedHosts.map(host => host.hostId)
const hostNames = pkg.affectedHosts.map(host => host.friendlyName)
const affectedHosts = pkg.affectedHosts || []
const hostIds = affectedHosts.map(host => host.hostId)
const hostNames = affectedHosts.map(host => host.friendlyName)
// Create URL with selected hosts and filter
const params = new URLSearchParams()
@@ -128,8 +129,9 @@ const Packages = () => {
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
const affectedHosts = pkg.affectedHosts || []
const matchesHost = hostFilter === 'all' ||
pkg.affectedHosts.some(host => host.hostId === hostFilter)
affectedHosts.some(host => host.hostId === hostFilter)
return matchesSearch && matchesCategory && matchesSecurity && matchesHost
})
@@ -148,8 +150,8 @@ const Packages = () => {
bValue = b.latestVersion?.toLowerCase() || ''
break
case 'affectedHosts':
aValue = a.affectedHostsCount || 0
bValue = b.affectedHostsCount || 0
aValue = a.affectedHostsCount || a.affectedHosts?.length || 0
bValue = b.affectedHostsCount || b.affectedHosts?.length || 0
break
case 'priority':
aValue = a.isSecurityUpdate ? 0 : 1 // Security updates first
@@ -241,14 +243,15 @@ const Packages = () => {
</div>
)
case 'affectedHosts':
const affectedHostsCount = pkg.affectedHostsCount || pkg.affectedHosts?.length || 0
return (
<button
onClick={() => handleAffectedHostsClick(pkg)}
className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group"
title={`Click to view all ${pkg.affectedHostsCount} affected hosts`}
title={`Click to view all ${affectedHostsCount} affected hosts`}
>
<div className="text-sm text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
{pkg.affectedHostsCount} host{pkg.affectedHostsCount !== 1 ? 's' : ''}
{affectedHostsCount} host{affectedHostsCount !== 1 ? 's' : ''}
</div>
</button>
)
@@ -278,7 +281,8 @@ const Packages = () => {
// Calculate unique affected hosts
const uniqueAffectedHosts = new Set()
packages?.forEach(pkg => {
pkg.affectedHosts.forEach(host => {
const affectedHosts = pkg.affectedHosts || []
affectedHosts.forEach(host => {
uniqueAffectedHosts.add(host.hostId)
})
})
@@ -458,7 +462,7 @@ const Packages = () => {
>
<option value="all">All Hosts</option>
{hosts?.map(host => (
<option key={host.id} value={host.id}>{host.friendlyName}</option>
<option key={host.id} value={host.id}>{host.friendly_name}</option>
))}
</select>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Shield,
@@ -145,17 +145,22 @@ const Permissions = () => {
const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => {
const [permissions, setPermissions] = useState(role)
// Sync permissions state with role prop when it changes
useEffect(() => {
setPermissions(role)
}, [role])
const permissionFields = [
{ key: 'canViewDashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' },
{ key: 'canViewHosts', label: 'View Hosts', icon: Server, description: 'See host information and status' },
{ key: 'canManageHosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' },
{ key: 'canViewPackages', label: 'View Packages', icon: Package, description: 'See package information' },
{ key: 'canManagePackages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' },
{ key: 'canViewUsers', label: 'View Users', icon: Users, description: 'See user list and details' },
{ key: 'canManageUsers', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' },
{ key: 'canViewReports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' },
{ key: 'canExportData', label: 'Export Data', icon: Download, description: 'Download data and reports' },
{ key: 'canManageSettings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' }
{ key: 'can_view_dashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' },
{ key: 'can_view_hosts', label: 'View Hosts', icon: Server, description: 'See host information and status' },
{ key: 'can_manage_hosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' },
{ key: 'can_view_packages', label: 'View Packages', icon: Package, description: 'See package information' },
{ key: 'can_manage_packages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' },
{ key: 'can_view_users', label: 'View Users', icon: Users, description: 'See user list and details' },
{ key: 'can_manage_users', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' },
{ key: 'can_view_reports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' },
{ key: 'can_export_data', label: 'Export Data', icon: Download, description: 'Download data and reports' },
{ key: 'can_manage_settings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' }
]
const handlePermissionChange = (key, value) => {
@@ -196,7 +201,7 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
</button>
<button
onClick={onCancel}
className="inline-flex items-center px-3 py-1 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50"
className="inline-flex items-center px-3 py-1 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
<X className="h-4 w-4 mr-1" />
Cancel
@@ -240,7 +245,7 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
type="checkbox"
checked={isChecked}
onChange={(e) => handlePermissionChange(field.key, e.target.checked)}
disabled={!isEditing || (isAdminRole && field.key === 'canManageUsers')}
disabled={!isEditing || (isAdminRole && field.key === 'can_manage_users')}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
/>
</div>
@@ -268,16 +273,16 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
role: '',
canViewDashboard: true,
canViewHosts: true,
canManageHosts: false,
canViewPackages: true,
canManagePackages: false,
canViewUsers: false,
canManageUsers: false,
canViewReports: true,
canExportData: false,
canManageSettings: false
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: false,
can_view_packages: true,
can_manage_packages: false,
can_view_users: false,
can_manage_users: false,
can_view_reports: true,
can_export_data: false,
can_manage_settings: false
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
@@ -309,12 +314,12 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-secondary-900 mb-4">Add New Role</h3>
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Add New Role</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Role Name
</label>
<input
@@ -323,25 +328,25 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
required
value={formData.role}
onChange={handleInputChange}
className="block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
placeholder="e.g., host_manager, readonly"
/>
<p className="mt-1 text-xs text-secondary-500">Use lowercase with underscores (e.g., host_manager)</p>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">Use lowercase with underscores (e.g., host_manager)</p>
</div>
<div className="space-y-3">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">Permissions</h4>
{[
{ key: 'canViewDashboard', label: 'View Dashboard' },
{ key: 'canViewHosts', label: 'View Hosts' },
{ key: 'canManageHosts', label: 'Manage Hosts' },
{ key: 'canViewPackages', label: 'View Packages' },
{ key: 'canManagePackages', label: 'Manage Packages' },
{ key: 'canViewUsers', label: 'View Users' },
{ key: 'canManageUsers', label: 'Manage Users' },
{ key: 'canViewReports', label: 'View Reports' },
{ key: 'canExportData', label: 'Export Data' },
{ key: 'canManageSettings', label: 'Manage Settings' }
{ key: 'can_view_dashboard', label: 'View Dashboard' },
{ key: 'can_view_hosts', label: 'View Hosts' },
{ key: 'can_manage_hosts', label: 'Manage Hosts' },
{ key: 'can_view_packages', label: 'View Packages' },
{ key: 'can_manage_packages', label: 'Manage Packages' },
{ key: 'can_view_users', label: 'View Users' },
{ key: 'can_manage_users', label: 'Manage Users' },
{ key: 'can_view_reports', label: 'View Reports' },
{ key: 'can_export_data', label: 'Export Data' },
{ key: 'can_manage_settings', label: 'Manage Settings' }
].map((permission) => (
<div key={permission.key} className="flex items-center">
<input
@@ -351,7 +356,7 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label className="ml-2 block text-sm text-secondary-700">
<label className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200">
{permission.label}
</label>
</div>
@@ -359,8 +364,8 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<p className="text-sm text-danger-700">{error}</p>
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
</div>
)}
@@ -368,7 +373,7 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 bg-white border border-secondary-300 rounded-md hover:bg-secondary-50"
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>

View File

@@ -517,9 +517,45 @@ const TfaTab = () => {
regenerateBackupCodesMutation.mutate()
}
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text)
setMessage({ type: 'success', text: 'Copied to clipboard!' })
const copyToClipboard = async (text) => {
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
setMessage({ type: 'success', text: 'Copied to clipboard!' })
return
}
// Fallback for older browsers or non-secure contexts
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
setMessage({ type: 'success', text: 'Copied to clipboard!' })
} else {
throw new Error('Copy command failed')
}
} catch (err) {
// If all else fails, show the text in a prompt
prompt('Copy this text:', text)
setMessage({ type: 'info', text: 'Text shown in prompt for manual copying' })
} finally {
document.body.removeChild(textArea)
}
} catch (err) {
console.error('Failed to copy to clipboard:', err)
// Show the text in a prompt as last resort
prompt('Copy this text:', text)
setMessage({ type: 'info', text: 'Text shown in prompt for manual copying' })
}
}
const downloadBackupCodes = () => {

View File

@@ -19,7 +19,8 @@ import {
ArrowDown,
X,
GripVertical,
Check
Check,
RefreshCw
} from 'lucide-react';
import { repositoryAPI } from '../utils/api';
@@ -60,7 +61,7 @@ const Repositories = () => {
};
// Fetch repositories
const { data: repositories = [], isLoading, error } = useQuery({
const { data: repositories = [], isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['repositories'],
queryFn: () => repositoryAPI.list().then(res => res.data)
});
@@ -132,14 +133,6 @@ const Repositories = () => {
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
// Debug logging
console.log('Filtering repo:', {
name: repo.name,
isSecure: repo.isSecure,
filterType,
url: repo.url
});
// Check security based on URL if isSecure property doesn't exist
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
@@ -151,13 +144,6 @@ const Repositories = () => {
(filterStatus === 'active' && repo.is_active === true) ||
(filterStatus === 'inactive' && repo.is_active === false);
console.log('Filter results:', {
matchesSearch,
matchesType,
matchesStatus,
final: matchesSearch && matchesType && matchesStatus
});
return matchesSearch && matchesType && matchesStatus;
});
@@ -211,6 +197,26 @@ const Repositories = () => {
return (
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Repositories</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Manage and monitor your package repositories
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
title="Refresh repositories data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
@@ -437,7 +443,7 @@ const Repositories = () => {
return (
<div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white">
<Users className="h-4 w-4" />
<span>{repo.hostCount}</span>
<span>{repo.host_count}</span>
</div>
)
case 'actions':

View File

@@ -310,10 +310,10 @@ const RepositoryDetail = () => {
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
<Users className="h-5 w-5" />
Hosts Using This Repository ({repository.hostRepositories?.length || 0})
Hosts Using This Repository ({repository.host_repositories?.length || 0})
</h2>
</div>
{!repository.hostRepositories || repository.hostRepositories.length === 0 ? (
{!repository.host_repositories || repository.host_repositories.length === 0 ? (
<div className="px-6 py-12 text-center">
<Server className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No hosts using this repository</h3>
@@ -323,28 +323,28 @@ const RepositoryDetail = () => {
</div>
) : (
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
{repository.hostRepositories.map((hostRepo) => (
{repository.host_repositories.map((hostRepo) => (
<div key={hostRepo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${
hostRepo.host.status === 'active'
hostRepo.hosts.status === 'active'
? 'bg-green-500'
: hostRepo.host.status === 'pending'
: hostRepo.hosts.status === 'pending'
? 'bg-yellow-500'
: 'bg-red-500'
}`} />
<div>
<Link
to={`/hosts/${hostRepo.host.id}`}
to={`/hosts/${hostRepo.hosts.id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{hostRepo.host.friendly_name}
{hostRepo.hosts.friendly_name}
</Link>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
<span>IP: {hostRepo.host.ip}</span>
<span>OS: {hostRepo.host.osType} {hostRepo.host.osVersion}</span>
<span>Last Update: {new Date(hostRepo.host.lastUpdate).toLocaleDateString()}</span>
<span>IP: {hostRepo.hosts.ip}</span>
<span>OS: {hostRepo.hosts.os_type} {hostRepo.hosts.os_version}</span>
<span>Last Update: {new Date(hostRepo.hosts.last_update).toLocaleDateString()}</span>
</div>
</div>
</div>
@@ -352,7 +352,7 @@ const RepositoryDetail = () => {
<div className="text-center">
<div className="text-xs text-secondary-500 dark:text-secondary-400">Last Checked</div>
<div className="text-sm text-secondary-900 dark:text-white">
{new Date(hostRepo.lastChecked).toLocaleDateString()}
{new Date(hostRepo.last_checked).toLocaleDateString()}
</div>
</div>
</div>

View File

@@ -13,6 +13,7 @@ const Settings = () => {
frontendUrl: 'http://localhost:3000',
updateInterval: 60,
autoUpdate: false,
signupEnabled: false,
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: 'public',
sshKeyPath: '',
@@ -72,8 +73,6 @@ const Settings = () => {
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
console.log('Settings loaded:', settings);
console.log('updateInterval from settings:', settings.update_interval);
const newFormData = {
serverProtocol: settings.server_protocol || 'http',
serverHost: settings.server_host || 'localhost',
@@ -81,12 +80,12 @@ const Settings = () => {
frontendUrl: settings.frontend_url || 'http://localhost:3000',
updateInterval: settings.update_interval || 60,
autoUpdate: settings.auto_update || false,
signupEnabled: settings.signup_enabled === true ? true : false, // Explicit boolean conversion
githubRepoUrl: settings.github_repo_url || 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: settings.repository_type || 'public',
sshKeyPath: settings.ssh_key_path || '',
useCustomSshKey: !!settings.ssh_key_path
};
console.log('Setting form data to:', newFormData);
setFormData(newFormData);
setIsDirty(false);
}
@@ -95,34 +94,14 @@ const Settings = () => {
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (data) => {
console.log('Mutation called with data:', data);
return settingsAPI.update(data).then(res => {
console.log('API response:', res);
return res.data;
});
return settingsAPI.update(data).then(res => res.data);
},
onSuccess: (data) => {
console.log('Mutation success:', data);
console.log('Invalidating queries and updating form data');
queryClient.invalidateQueries(['settings']);
// Update form data with the returned data
setFormData({
serverProtocol: data.settings?.server_protocol || data.server_protocol || 'http',
serverHost: data.settings?.server_host || data.server_host || 'localhost',
serverPort: data.settings?.server_port || data.server_port || 3001,
frontendUrl: data.settings?.frontend_url || data.frontend_url || 'http://localhost:3000',
updateInterval: data.settings?.update_interval || data.update_interval || 60,
autoUpdate: data.settings?.auto_update || data.auto_update || false,
githubRepoUrl: data.settings?.github_repo_url || data.github_repo_url || 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: data.settings?.repository_type || data.repository_type || 'public',
sshKeyPath: data.settings?.ssh_key_path || data.ssh_key_path || '',
useCustomSshKey: !!(data.settings?.ssh_key_path || data.ssh_key_path)
});
setIsDirty(false);
setErrors({});
},
onError: (error) => {
console.log('Mutation error:', error);
if (error.response?.data?.errors) {
setErrors(error.response.data.errors.reduce((acc, err) => {
acc[err.path] = err.msg;
@@ -138,20 +117,12 @@ const Settings = () => {
const { data: agentVersions, isLoading: agentVersionsLoading, error: agentVersionsError } = useQuery({
queryKey: ['agentVersions'],
queryFn: () => {
console.log('Fetching agent versions...');
return agentVersionAPI.list().then(res => {
console.log('Agent versions API response:', res);
return res.data;
});
}
});
// Debug agent versions
useEffect(() => {
console.log('Agent versions data:', agentVersions);
console.log('Agent versions loading:', agentVersionsLoading);
console.log('Agent versions error:', agentVersionsError);
}, [agentVersions, agentVersionsLoading, agentVersionsError]);
// Load current version on component mount
useEffect(() => {
@@ -213,7 +184,7 @@ const Settings = () => {
currentVersion: data.currentVersion,
latestVersion: data.latestVersion,
isUpdateAvailable: data.isUpdateAvailable,
lastUpdateCheck: data.lastUpdateCheck,
last_update_check: data.last_update_check,
checking: false,
error: null
});
@@ -264,10 +235,8 @@ const Settings = () => {
};
const handleInputChange = (field, value) => {
console.log(`handleInputChange: ${field} = ${value}`);
setFormData(prev => {
const newData = { ...prev, [field]: value };
console.log('New form data:', newData);
return newData;
});
setIsDirty(true);
@@ -316,10 +285,7 @@ const Settings = () => {
};
const handleSave = () => {
console.log('Saving settings:', formData);
if (validateForm()) {
console.log('Validation passed, calling mutation');
// Prepare data for submission
const dataToSubmit = { ...formData };
if (!dataToSubmit.useCustomSshKey) {
@@ -328,10 +294,7 @@ const Settings = () => {
// Remove the frontend-only field
delete dataToSubmit.useCustomSshKey;
console.log('Submitting data with githubRepoUrl:', dataToSubmit.githubRepoUrl);
updateSettingsMutation.mutate(dataToSubmit);
} else {
console.log('Validation failed:', errors);
}
};
@@ -489,7 +452,6 @@ const Settings = () => {
max="1440"
value={formData.updateInterval}
onChange={(e) => {
console.log('Update interval input changed:', e.target.value);
handleInputChange('updateInterval', parseInt(e.target.value) || 60);
}}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
@@ -523,6 +485,24 @@ const Settings = () => {
</p>
</div>
{/* User Signup Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.signupEnabled}
onChange={(e) => handleInputChange('signupEnabled', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
Enable User Self-Registration
</div>
</label>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, users can create their own accounts through the signup page. When disabled, only administrators can create user accounts.
</p>
</div>
{/* Security Notice */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
@@ -970,14 +950,14 @@ const Settings = () => {
</div>
{/* Last Checked Time */}
{versionInfo.lastUpdateCheck && (
{versionInfo.last_update_check && (
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Last Checked</span>
</div>
<span className="text-sm text-secondary-600 dark:text-secondary-400">
{new Date(versionInfo.lastUpdateCheck).toLocaleString()}
{new Date(versionInfo.last_update_check).toLocaleString()}
</span>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
Updates are checked automatically every 24 hours

View File

@@ -60,6 +60,7 @@ export const adminHostsAPI = {
create: (data) => api.post('/hosts/create', data),
list: () => api.get('/hosts/admin/list'),
delete: (hostId) => api.delete(`/hosts/${hostId}`),
deleteBulk: (hostIds) => api.delete('/hosts/bulk', { data: { hostIds } }),
regenerateCredentials: (hostId) => api.post(`/hosts/${hostId}/regenerate-credentials`),
updateGroup: (hostId, hostGroupId) => api.put(`/hosts/${hostId}/group`, { hostGroupId }),
bulkUpdateGroup: (hostIds, hostGroupId) => api.put('/hosts/bulk/group', { hostIds, hostGroupId }),