feat: Add Server Version management with GitHub integration

- Add Server Version tab in settings
- Add githubRepoUrl field to Settings model
- Add database migration for github_repo_url
- Update settings API to handle GitHub repo URL
- Add version checking UI with current/latest version display
- Default GitHub repo: git@github.com:9technologygroup/patchmon.net.git
This commit is contained in:
Muhammad Ibrahim
2025-09-17 22:07:30 +01:00
parent 9714be788b
commit f42c6cc185
16 changed files with 2442 additions and 617 deletions

View File

@@ -18,7 +18,8 @@ import {
Clock,
RefreshCw,
GitBranch,
Wrench
Wrench,
Plus
} from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
@@ -59,7 +60,7 @@ const Layout = ({ children }) => {
]
},
{
section: 'Users',
section: 'PatchMon Users',
items: [
...(canViewUsers() ? [{ name: 'Users', href: '/users', icon: Users }] : []),
...(canManageSettings() ? [{ name: 'Permissions', href: '/permissions', icon: Shield }] : []),
@@ -68,7 +69,7 @@ const Layout = ({ children }) => {
{
section: 'Settings',
items: [
...(canManageSettings() ? [{ name: 'Settings', href: '/settings', icon: Settings }] : []),
...(canManageSettings() ? [{ name: 'Server Config', href: '/settings', icon: Settings }] : []),
]
}
]
@@ -100,6 +101,26 @@ const Layout = ({ children }) => {
setUserMenuOpen(false)
}
const handleAddHost = () => {
// Navigate to hosts page with add modal parameter
window.location.href = '/hosts?action=add'
}
// Short format for navigation area
const formatRelativeTimeShort = (date) => {
const now = new Date()
const diff = now - new Date(date)
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return `${seconds}s ago`
}
// Save sidebar collapsed state to localStorage
useEffect(() => {
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed))
@@ -168,26 +189,57 @@ const Layout = ({ children }) => {
</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
<div key={subItem.name}>
{subItem.name === 'Hosts' && canManageHosts() ? (
// Special handling for Hosts item with integrated + button (mobile)
<Link
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'
}`}
onClick={() => setSidebarOpen(false)}
>
<subItem.icon className="mr-3 h-5 w-5" />
<span className="flex items-center gap-2 flex-1">
{subItem.name}
</span>
)}
</span>
</Link>
<button
onClick={(e) => {
e.preventDefault()
setSidebarOpen(false)
handleAddHost()
}}
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
title="Add Host"
>
<Plus className="h-3 w-3" />
</button>
</Link>
) : (
// Standard navigation item (mobile)
<Link
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>
</div>
@@ -268,30 +320,65 @@ const Layout = ({ children }) => {
<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
{subItem.name === 'Hosts' && canManageHosts() ? (
// Special handling for Hosts item with integrated + button
<div className="flex items-center gap-1">
<Link
to={subItem.href}
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 flex-1 ${
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'}`}
title={sidebarCollapsed ? subItem.name : ''}
>
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2 flex-1">
{subItem.name}
</span>
)}
</span>
)}
</Link>
{!sidebarCollapsed && (
<button
onClick={(e) => {
e.preventDefault()
handleAddHost()
}}
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
title="Add Host"
>
<Plus className="h-3 w-3" />
</button>
)}
</Link>
</div>
) : (
// Standard navigation item
<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>
@@ -304,39 +391,41 @@ const Layout = ({ children }) => {
</nav>
{/* Profile Section - Bottom of Sidebar */}
<div className="border-t border-secondary-200">
<div className="border-t border-secondary-200 dark:border-secondary-600">
{!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>
{/* User Info with Sign Out - Username is clickable */}
<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
<Link
to="/profile"
className={`flex-1 min-w-0 rounded-md p-2 transition-all duration-200 ${
isActive('/profile')
? 'bg-primary-50 dark:bg-primary-600'
: 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
}`}
>
<div className="flex items-center gap-x-3">
<UserCircle className={`h-5 w-5 shrink-0 ${
isActive('/profile')
? 'text-primary-700 dark:text-white'
: 'text-secondary-500 dark:text-secondary-400'
}`} />
<div className="flex items-center gap-x-2">
<span className={`text-sm leading-6 font-semibold truncate ${
isActive('/profile')
? 'text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200'
}`}>
{user?.username}
</span>
)}
{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>
</div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 truncate">
{user?.email}
</p>
</div>
</Link>
<button
onClick={handleLogout}
className="ml-2 p-1.5 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded transition-colors"
@@ -345,23 +434,55 @@ const Layout = ({ children }) => {
<LogOut className="h-4 w-4" />
</button>
</div>
{/* Updated info */}
{stats && (
<div className="px-3 py-1 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-x-2 text-xs text-secondary-500 dark:text-secondary-400">
<Clock className="h-3 w-3" />
<span>Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
<button
onClick={() => refetch()}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
title="Refresh data"
>
<RefreshCw className="h-3 w-3" />
</button>
</div>
</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"
className={`flex items-center justify-center p-2 rounded-md transition-colors ${
isActive('/profile')
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700'
}`}
title={`My Profile (${user?.username})`}
>
<UserCircle className="h-5 w-5 text-white" />
<UserCircle className="h-5 w-5" />
</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"
className="flex items-center justify-center w-full p-2 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-md transition-colors"
title="Sign out"
>
<LogOut className="h-5 w-5 text-white" />
<LogOut className="h-4 w-4" />
</button>
{/* Updated info for collapsed sidebar */}
{stats && (
<div className="flex justify-center py-1 border-t border-secondary-200 dark:border-secondary-700">
<button
onClick={() => refetch()}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
title={`Refresh data - Updated: ${formatRelativeTimeShort(stats.lastUpdated)}`}
>
<RefreshCw className="h-3 w-3" />
</button>
</div>
)}
</div>
)}
</div>
@@ -369,7 +490,7 @@ const Layout = ({ children }) => {
</div>
{/* Main content */}
<div className={`transition-all duration-300 ${
<div className={`flex flex-col min-h-screen transition-all duration-300 ${
sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-64'
}`}>
{/* Top bar */}
@@ -392,22 +513,6 @@ const Layout = ({ children }) => {
</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
@@ -426,7 +531,7 @@ const Layout = ({ children }) => {
</div>
</div>
<main className="py-6 bg-secondary-50 dark:bg-secondary-800 min-h-screen">
<main className="flex-1 py-6 bg-secondary-50 dark:bg-secondary-800">
<div className="px-4 sm:px-6 lg:px-8">
{children}
</div>

View File

@@ -12,7 +12,7 @@ import {
} 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, formatRelativeTime } from '../utils/api'
import { dashboardAPI, dashboardPreferencesAPI, settingsAPI, formatRelativeTime } from '../utils/api'
import DashboardSettingsModal from '../components/DashboardSettingsModal'
import { useTheme } from '../contexts/ThemeContext'
@@ -42,6 +42,48 @@ const Dashboard = () => {
navigate('/packages?filter=security')
}
const handleErroredHostsClick = () => {
navigate('/hosts?filter=inactive')
}
const handleOSDistributionClick = () => {
navigate('/hosts')
}
const handleUpdateStatusClick = () => {
navigate('/hosts')
}
const handlePackagePriorityClick = () => {
navigate('/packages?filter=security')
}
// Helper function to format the update interval threshold
const formatUpdateIntervalThreshold = () => {
if (!settings?.updateInterval) return '24 hours'
const intervalMinutes = settings.updateInterval
const thresholdMinutes = intervalMinutes * 2 // 2x the update interval
if (thresholdMinutes < 60) {
return `${thresholdMinutes} minutes`
} else if (thresholdMinutes < 1440) {
const hours = Math.floor(thresholdMinutes / 60)
const minutes = thresholdMinutes % 60
if (minutes === 0) {
return `${hours} hour${hours > 1 ? 's' : ''}`
}
return `${hours}h ${minutes}m`
} else {
const days = Math.floor(thresholdMinutes / 1440)
const hours = Math.floor((thresholdMinutes % 1440) / 60)
if (hours === 0) {
return `${days} day${days > 1 ? 's' : ''}`
}
return `${days}d ${hours}h`
}
}
const { data: stats, isLoading, error, refetch } = useQuery({
queryKey: ['dashboardStats'],
queryFn: () => dashboardAPI.getStats().then(res => res.data),
@@ -49,6 +91,12 @@ const Dashboard = () => {
staleTime: 30000, // Consider data stale after 30 seconds
})
// Fetch settings to get the agent update interval
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: () => settingsAPI.get().then(res => res.data),
})
// Fetch user's dashboard preferences
const { data: preferences, refetch: refetchPreferences } = useQuery({
queryKey: ['dashboardPreferences'],
@@ -210,11 +258,14 @@ const Dashboard = () => {
case 'erroredHosts':
return (
<div className={`border rounded-lg p-4 ${
stats.cards.erroredHosts > 0
? 'bg-danger-50 border-danger-200'
: 'bg-success-50 border-success-200'
}`}>
<div
className={`border rounded-lg p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 ${
stats.cards.erroredHosts > 0
? 'bg-danger-50 border-danger-200'
: 'bg-success-50 border-success-200'
}`}
onClick={handleErroredHostsClick}
>
<div className="flex">
<AlertTriangle className={`h-5 w-5 ${
stats.cards.erroredHosts > 0 ? 'text-danger-400' : 'text-success-400'
@@ -223,7 +274,7 @@ const Dashboard = () => {
{stats.cards.erroredHosts > 0 ? (
<>
<h3 className="text-sm font-medium text-danger-800">
{stats.cards.erroredHosts} host{stats.cards.erroredHosts > 1 ? 's' : ''} haven't reported in 24+ hours
{stats.cards.erroredHosts} host{stats.cards.erroredHosts > 1 ? 's' : ''} haven't reported in {formatUpdateIntervalThreshold()}+
</h3>
<p className="text-sm text-danger-700 mt-1">
These hosts may be offline or experiencing connectivity issues.
@@ -235,7 +286,7 @@ const Dashboard = () => {
All hosts are reporting normally
</h3>
<p className="text-sm text-success-700 mt-1">
No hosts have failed to report in the last 24 hours.
No hosts have failed to report in the last {formatUpdateIntervalThreshold()}.
</p>
</>
)}
@@ -246,7 +297,10 @@ const Dashboard = () => {
case 'osDistribution':
return (
<div className="card p-6">
<div
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleOSDistributionClick}
>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">OS Distribution</h3>
<div className="h-64">
<Pie data={osChartData} options={chartOptions} />
@@ -256,7 +310,10 @@ const Dashboard = () => {
case 'updateStatus':
return (
<div className="card p-6">
<div
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleUpdateStatusClick}
>
<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} />
@@ -266,7 +323,10 @@ const Dashboard = () => {
case 'packagePriority':
return (
<div className="card p-6">
<div
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handlePackagePriorityClick}
>
<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} />

View File

@@ -21,7 +21,9 @@ import {
Code,
EyeOff,
ToggleLeft,
ToggleRight
ToggleRight,
Edit,
Check
} from 'lucide-react'
import { dashboardAPI, adminHostsAPI, settingsAPI, formatRelativeTime, formatDate } from '../utils/api'
@@ -31,6 +33,8 @@ const HostDetail = () => {
const queryClient = useQueryClient()
const [showCredentialsModal, setShowCredentialsModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isEditingHostname, setIsEditingHostname] = useState(false)
const [editedHostname, setEditedHostname] = useState('')
const { data: host, isLoading, error, refetch } = useQuery({
queryKey: ['host', hostId],
@@ -39,6 +43,13 @@ const HostDetail = () => {
staleTime: 30000,
})
// Auto-show credentials modal for new/pending hosts
React.useEffect(() => {
if (host && host.status === 'pending') {
setShowCredentialsModal(true)
}
}, [host])
const deleteHostMutation = useMutation({
mutationFn: (hostId) => adminHostsAPI.delete(hostId),
onSuccess: () => {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link, useSearchParams } from 'react-router-dom'
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
import {
Server,
AlertTriangle,
@@ -88,67 +88,105 @@ const AddHostModal = ({ 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-md">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900">Add New Host</h3>
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Add New Host</h3>
<button onClick={onClose} 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>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-secondary-700">Hostname *</label>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">Hostname *</label>
<input
type="text"
required
value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
className="mt-1 block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
placeholder="server.example.com"
/>
<p className="mt-1 text-sm text-secondary-500">
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
System information (OS, IP, architecture) will be automatically detected when the agent connects.
</p>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">Host Group</label>
<select
value={formData.hostGroupId}
onChange={(e) => setFormData({ ...formData, hostGroupId: e.target.value })}
className="mt-1 block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
>
<option value="">No group (ungrouped)</option>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">Host Group</label>
<div className="grid grid-cols-3 gap-2">
{/* No Group Option */}
<button
type="button"
onClick={() => setFormData({ ...formData, hostGroupId: '' })}
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 === ''
? '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 === '' && (
<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>
)}
</button>
{/* Host Group Options */}
{hostGroups?.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
<button
key={group.id}
type="button"
onClick={() => setFormData({ ...formData, hostGroupId: 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
? '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="flex items-center gap-1 mb-1 w-full justify-center">
{group.color && (
<div
className="w-3 h-3 rounded-full border border-secondary-300 dark:border-secondary-500 flex-shrink-0"
style={{ backgroundColor: group.color }}
></div>
)}
<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 && (
<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>
)}
</button>
))}
</select>
<p className="mt-1 text-sm text-secondary-500">
</div>
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
Optional: Assign this host to a group for better organization.
</p>
</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>
)}
<div className="flex justify-end space-x-3">
<div className="flex justify-end space-x-3 pt-2">
<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-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-all duration-200"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
className="px-6 py-3 text-sm font-medium text-white bg-primary-600 border-2 border-transparent rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-all duration-200"
>
{isSubmitting ? 'Creating...' : 'Create Host'}
</button>
@@ -564,6 +602,7 @@ const Hosts = () => {
const [selectedHosts, setSelectedHosts] = useState([])
const [showBulkAssignModal, setShowBulkAssignModal] = useState(false)
const [searchParams] = useSearchParams()
const navigate = useNavigate()
// Table state
const [searchTerm, setSearchTerm] = useState('')
@@ -575,15 +614,35 @@ const Hosts = () => {
const [showFilters, setShowFilters] = useState(false)
const [groupBy, setGroupBy] = useState('none')
const [showColumnSettings, setShowColumnSettings] = useState(false)
const [hideStale, setHideStale] = useState(false)
// Handle URL filter parameters
useEffect(() => {
const filter = searchParams.get('filter')
if (filter === 'needsUpdates') {
setShowFilters(true)
setStatusFilter('all')
// We'll filter hosts with updates > 0 in the filtering logic
} else if (filter === 'inactive') {
setShowFilters(true)
setStatusFilter('inactive')
// We'll filter hosts with inactive status in the filtering logic
} else if (filter === 'upToDate') {
setShowFilters(true)
setStatusFilter('active')
// We'll filter hosts that are up to date in the filtering logic
}
}, [searchParams])
// Handle add host action from navigation
const action = searchParams.get('action')
if (action === 'add') {
setShowAddModal(true)
// Remove the action parameter from URL without triggering a page reload
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.delete('action')
navigate(`/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`, { replace: true })
}
}, [searchParams, navigate])
// Column configuration
const [columnConfig, setColumnConfig] = useState(() => {
@@ -726,16 +785,22 @@ const Hosts = () => {
(groupFilter !== 'ungrouped' && host.hostGroup?.id === groupFilter)
// Status filter
const matchesStatus = statusFilter === 'all' || host.status === statusFilter
const matchesStatus = statusFilter === 'all' || (host.effectiveStatus || host.status) === statusFilter
// OS filter
const matchesOs = osFilter === 'all' || host.osType?.toLowerCase() === osFilter.toLowerCase()
// URL filter for hosts needing updates
// URL filter for hosts needing updates, inactive hosts, or up-to-date hosts
const filter = searchParams.get('filter')
const matchesUrlFilter = filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0)
const matchesUrlFilter =
(filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0)) &&
(filter !== 'inactive' || (host.effectiveStatus || host.status) === 'inactive') &&
(filter !== 'upToDate' || (!host.isStale && host.updatesCount === 0))
return matchesSearch && matchesGroup && matchesStatus && matchesOs && matchesUrlFilter
// Hide stale filter
const matchesHideStale = !hideStale || !host.isStale
return matchesSearch && matchesGroup && matchesStatus && matchesOs && matchesUrlFilter && matchesHideStale
})
// Sorting
@@ -768,8 +833,8 @@ const Hosts = () => {
bValue = b.agentVersion?.toLowerCase() || 'zzz_no_version'
break
case 'status':
aValue = a.status
bValue = b.status
aValue = a.effectiveStatus || a.status
bValue = b.effectiveStatus || b.status
break
case 'updates':
aValue = a.updatesCount || 0
@@ -806,7 +871,7 @@ const Hosts = () => {
groupKey = host.hostGroup?.name || 'Ungrouped'
break
case 'status':
groupKey = host.status.charAt(0).toUpperCase() + host.status.slice(1)
groupKey = (host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)
break
case 'os':
groupKey = host.osType || 'Unknown'
@@ -957,7 +1022,7 @@ const Hosts = () => {
case 'status':
return (
<div className="text-sm text-secondary-900 dark:text-white">
{host.status.charAt(0).toUpperCase() + host.status.slice(1)}
{(host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)}
</div>
)
case 'updates':
@@ -989,7 +1054,50 @@ const Hosts = () => {
const handleHostCreated = (newHost) => {
queryClient.invalidateQueries(['hosts'])
// Host created successfully - user can view details for setup instructions
// Navigate to host detail page to show credentials and setup instructions
navigate(`/hosts/${newHost.hostId}`)
}
// Stats card click handlers
const handleTotalHostsClick = () => {
// Clear all filters to show all hosts
setSearchTerm('')
setGroupFilter('all')
setStatusFilter('all')
setOsFilter('all')
setGroupBy('none')
setHideStale(false)
setShowFilters(false)
}
const handleUpToDateClick = () => {
// Filter to show only up-to-date hosts
setStatusFilter('active')
setShowFilters(true)
// Use the upToDate URL filter
const newSearchParams = new URLSearchParams(window.location.search)
newSearchParams.set('filter', 'upToDate')
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
}
const handleNeedsUpdatesClick = () => {
// Filter to show hosts needing updates (regardless of status)
setStatusFilter('all')
setShowFilters(true)
// We'll use the existing needsUpdates URL filter logic
const newSearchParams = new URLSearchParams(window.location.search)
newSearchParams.set('filter', 'needsUpdates')
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
}
const handleStaleClick = () => {
// Filter to show stale/inactive hosts
setStatusFilter('inactive')
setShowFilters(true)
// We'll use the existing inactive URL filter logic
const newSearchParams = new URLSearchParams(window.location.search)
newSearchParams.set('filter', 'inactive')
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
}
if (isLoading) {
@@ -1045,7 +1153,10 @@ const Hosts = () => {
{/* Stats Summary */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
<div className="card p-4">
<div
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleTotalHostsClick}
>
<div className="flex items-center">
<Server className="h-5 w-5 text-primary-600 mr-2" />
<div>
@@ -1054,7 +1165,10 @@ const Hosts = () => {
</div>
</div>
</div>
<div className="card p-4">
<div
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleUpToDateClick}
>
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-success-600 mr-2" />
<div>
@@ -1065,18 +1179,24 @@ const Hosts = () => {
</div>
</div>
</div>
<div className="card p-4">
<div
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleNeedsUpdatesClick}
>
<div className="flex items-center">
<Clock className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Needs Updates</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{hosts?.filter(h => !h.isStale && h.updatesCount > 0).length || 0}
{hosts?.filter(h => h.updatesCount > 0).length || 0}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleStaleClick}
>
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-danger-600 mr-2" />
<div>
@@ -1150,15 +1270,22 @@ const Hosts = () => {
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 pr-8 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors"
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[120px]"
>
<option value="none">No Grouping</option>
<option value="group">Group by Host Group</option>
<option value="status">Group by Status</option>
<option value="os">Group by OS</option>
<option value="group">By Group</option>
<option value="status">By Status</option>
<option value="os">By OS</option>
</select>
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500 pointer-events-none" />
<ChevronDown className="absolute right-1 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500 pointer-events-none" />
</div>
<button
onClick={() => setHideStale(!hideStale)}
className={`btn-outline flex items-center gap-2 ${hideStale ? 'bg-primary-50 border-primary-300' : ''}`}
>
<AlertTriangle className="h-4 w-4" />
Hide Stale
</button>
<button
onClick={() => setShowAddModal(true)}
className="btn-primary flex items-center gap-2"
@@ -1222,6 +1349,7 @@ const Hosts = () => {
setStatusFilter('all')
setOsFilter('all')
setGroupBy('none')
setHideStale(false)
}}
className="btn-outline w-full"
>
@@ -1356,15 +1484,28 @@ const Hosts = () => {
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{groupHosts.map((host) => (
<tr key={host.id} className={`hover:bg-secondary-50 dark:hover:bg-secondary-700 ${selectedHosts.includes(host.id) ? 'bg-primary-50 dark:bg-primary-600' : ''}`}>
{visibleColumns.map((column) => (
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
{renderCellContent(column, host)}
</td>
))}
</tr>
))}
{groupHosts.map((host) => {
const isInactive = (host.effectiveStatus || host.status) === 'inactive'
const isSelected = selectedHosts.includes(host.id)
let rowClasses = 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
if (isSelected) {
rowClasses += ' bg-primary-50 dark:bg-primary-600'
} else if (isInactive) {
rowClasses += ' bg-red-50 dark:bg-red-900/20'
}
return (
<tr key={host.id} className={rowClasses}>
{visibleColumns.map((column) => (
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
{renderCellContent(column, host)}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>

View File

@@ -10,7 +10,8 @@ const Settings = () => {
serverPort: 3001,
frontendUrl: 'http://localhost:3000',
updateInterval: 60,
autoUpdate: false
autoUpdate: false,
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git'
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
@@ -22,7 +23,8 @@ const Settings = () => {
const tabs = [
{ id: 'server', name: 'Server Configuration', icon: Server },
{ id: 'frontend', name: 'Frontend Configuration', icon: Globe },
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon }
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon },
{ id: 'version', name: 'Server Version', icon: Code }
];
// Agent version management state
@@ -54,7 +56,8 @@ const Settings = () => {
serverPort: settings.serverPort || 3001,
frontendUrl: settings.frontendUrl || 'http://localhost:3000',
updateInterval: settings.updateInterval || 60,
autoUpdate: settings.autoUpdate || false
autoUpdate: settings.autoUpdate || false,
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
};
console.log('Setting form data to:', newFormData);
setFormData(newFormData);
@@ -653,6 +656,100 @@ const Settings = () => {
)}
</div>
)}
{/* Server Version Tab */}
{activeTab === 'version' && (
<div className="space-y-6">
<div className="flex items-center mb-6">
<Code className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Server Version Management</h2>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Version Check Configuration</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
Configure automatic version checking against your GitHub repository to notify users of available updates.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
GitHub Repository URL
</label>
<input
type="text"
value={formData.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'}
onChange={(e) => handleInputChange('githubRepoUrl', e.target.value)}
className="w-full border 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 font-mono text-sm"
placeholder="git@github.com:username/repository.git"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
SSH or HTTPS URL to your GitHub repository
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">1.2.3</span>
</div>
<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">
<Download 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">Latest Version</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">Checking...</span>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => {
// TODO: Implement version check
console.log('Checking for updates...');
}}
className="btn-primary flex items-center gap-2"
>
<Download className="h-4 w-4" />
Check for Updates
</button>
<button
onClick={() => {
// TODO: Implement update notification
console.log('Enable update notifications');
}}
className="btn-outline flex items-center gap-2"
>
<AlertCircle className="h-4 w-4" />
Enable Notifications
</button>
</div>
</div>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-amber-400 dark:text-amber-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">Setup Instructions</h3>
<div className="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p className="mb-2">To enable version checking, you need to:</p>
<ol className="list-decimal list-inside space-y-1 ml-4">
<li>Create a version tag (e.g., v1.2.3) in your GitHub repository</li>
<li>Ensure the repository is publicly accessible or configure access tokens</li>
<li>Click "Check for Updates" to verify the connection</li>
</ol>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>

View File

@@ -1,12 +1,13 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle } from 'lucide-react'
import { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle, Key } from 'lucide-react'
import { adminUsersAPI, permissionsAPI } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
const Users = () => {
const [showAddModal, setShowAddModal] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const [resetPasswordUser, setResetPasswordUser] = useState(null)
const queryClient = useQueryClient()
const { user: currentUser } = useAuth()
@@ -39,6 +40,15 @@ const Users = () => {
}
})
// Reset password mutation
const resetPasswordMutation = useMutation({
mutationFn: ({ userId, newPassword }) => adminUsersAPI.resetPassword(userId, newPassword),
onSuccess: () => {
queryClient.invalidateQueries(['users'])
setResetPasswordUser(null)
}
})
const handleDeleteUser = async (userId, username) => {
if (window.confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
try {
@@ -58,6 +68,10 @@ const Users = () => {
setEditingUser(user)
}
const handleResetPassword = (user) => {
setResetPasswordUser(user)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -156,6 +170,18 @@ const Users = () => {
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleResetPassword(user)}
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
title={
!user.isActive
? "Cannot reset password for inactive user"
: "Reset password"
}
disabled={!user.isActive}
>
<Key className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteUser(user.id, user.username)}
className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed"
@@ -209,6 +235,17 @@ const Users = () => {
roles={roles}
/>
)}
{/* Reset Password Modal */}
{resetPasswordUser && (
<ResetPasswordModal
user={resetPasswordUser}
isOpen={!!resetPasswordUser}
onClose={() => setResetPasswordUser(null)}
onPasswordReset={resetPasswordMutation.mutate}
isLoading={resetPasswordMutation.isPending}
/>
)}
</div>
)
}
@@ -487,4 +524,126 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
)
}
// Reset Password Modal Component
const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading }) => {
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
// Validate passwords
if (newPassword.length < 6) {
setError('Password must be at least 6 characters long')
return
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match')
return
}
try {
await onPasswordReset({ userId: user.id, newPassword })
// Reset form on success
setNewPassword('')
setConfirmPassword('')
} catch (err) {
setError(err.response?.data?.error || 'Failed to reset password')
}
}
const handleClose = () => {
setNewPassword('')
setConfirmPassword('')
setError('')
onClose()
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Reset Password for {user.username}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
New Password
</label>
<input
type="password"
required
minLength={6}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
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="Enter new password (min 6 characters)"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Confirm Password
</label>
<input
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
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="Confirm new password"
/>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-md p-3">
<div className="flex">
<div className="flex-shrink-0">
<Key className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Password Reset Warning
</h3>
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>This will immediately change the user's password. The user will need to use the new password to login.</p>
</div>
</div>
</div>
</div>
{error && (
<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>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={handleClose}
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>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center"
>
{isLoading && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>}
{isLoading ? 'Resetting...' : 'Reset Password'}
</button>
</div>
</form>
</div>
</div>
)
}
export default Users

View File

@@ -73,7 +73,8 @@ export const adminUsersAPI = {
list: () => api.get('/auth/admin/users'),
create: (userData) => api.post('/auth/admin/users', userData),
update: (userId, userData) => api.put(`/auth/admin/users/${userId}`, userData),
delete: (userId) => api.delete(`/auth/admin/users/${userId}`)
delete: (userId) => api.delete(`/auth/admin/users/${userId}`),
resetPassword: (userId, newPassword) => api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword })
}
// Permissions API (for role management)