mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-23 07:51:12 +00:00
Added mfa and css enhancements
This commit is contained in:
@@ -2,18 +2,19 @@ import React from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { UpdateNotificationProvider } from './contexts/UpdateNotificationContext'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import Layout from './components/Layout'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Hosts from './pages/Hosts'
|
||||
import HostGroups from './pages/HostGroups'
|
||||
import Packages from './pages/Packages'
|
||||
import Repositories from './pages/Repositories'
|
||||
import RepositoryDetail from './pages/RepositoryDetail'
|
||||
import Users from './pages/Users'
|
||||
import Permissions from './pages/Permissions'
|
||||
import Settings from './pages/Settings'
|
||||
import Options from './pages/Options'
|
||||
import Profile from './pages/Profile'
|
||||
import HostDetail from './pages/HostDetail'
|
||||
import PackageDetail from './pages/PackageDetail'
|
||||
@@ -22,7 +23,8 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<UpdateNotificationProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute requirePermission="canViewDashboard">
|
||||
@@ -45,13 +47,6 @@ function App() {
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/host-groups" element={
|
||||
<ProtectedRoute requirePermission="canManageHosts">
|
||||
<Layout>
|
||||
<HostGroups />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/packages" element={
|
||||
<ProtectedRoute requirePermission="canViewPackages">
|
||||
<Layout>
|
||||
@@ -94,6 +89,13 @@ function App() {
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/options" element={
|
||||
<ProtectedRoute requirePermission="canManageHosts">
|
||||
<Layout>
|
||||
<Options />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
@@ -108,7 +110,8 @@ function App() {
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</Routes>
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
@@ -19,12 +19,15 @@ import {
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
Wrench,
|
||||
Container,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { dashboardAPI, formatRelativeTime } from '../utils/api'
|
||||
import { useUpdateNotification } from '../contexts/UpdateNotificationContext'
|
||||
import { dashboardAPI, formatRelativeTime, versionAPI } from '../utils/api'
|
||||
import UpgradeNotificationIcon from './UpgradeNotificationIcon'
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
@@ -36,6 +39,7 @@ const Layout = ({ children }) => {
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const { user, logout, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canManageSettings } = useAuth()
|
||||
const { updateAvailable } = useUpdateNotification()
|
||||
const userMenuRef = useRef(null)
|
||||
|
||||
// Fetch dashboard stats for the "Last updated" info
|
||||
@@ -46,16 +50,23 @@ const Layout = ({ children }) => {
|
||||
staleTime: 30000, // Consider data stale after 30 seconds
|
||||
})
|
||||
|
||||
// Fetch version info
|
||||
const { data: versionInfo } = useQuery({
|
||||
queryKey: ['versionInfo'],
|
||||
queryFn: () => versionAPI.getCurrent().then(res => res.data),
|
||||
staleTime: 300000, // Consider data stale after 5 minutes
|
||||
})
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: Home },
|
||||
{
|
||||
section: 'Inventory',
|
||||
items: [
|
||||
...(canViewHosts() ? [{ name: 'Hosts', href: '/hosts', icon: Server }] : []),
|
||||
...(canManageHosts() ? [{ name: 'Host Groups', href: '/host-groups', icon: Users }] : []),
|
||||
...(canViewPackages() ? [{ name: 'Packages', href: '/packages', icon: Package }] : []),
|
||||
...(canViewHosts() ? [{ name: 'Repos', href: '/repositories', icon: GitBranch }] : []),
|
||||
{ name: 'Services', href: '/services', icon: Wrench, comingSoon: true },
|
||||
{ name: 'Docker', href: '/docker', icon: Container, comingSoon: true },
|
||||
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true },
|
||||
]
|
||||
},
|
||||
@@ -69,7 +80,17 @@ const Layout = ({ children }) => {
|
||||
{
|
||||
section: 'Settings',
|
||||
items: [
|
||||
...(canManageSettings() ? [{ name: 'Server Config', href: '/settings', icon: Settings }] : []),
|
||||
...(canManageSettings() ? [{
|
||||
name: 'Server Config',
|
||||
href: '/settings',
|
||||
icon: Settings,
|
||||
showUpgradeIcon: updateAvailable
|
||||
}] : []),
|
||||
...(canManageHosts() ? [{
|
||||
name: 'Options',
|
||||
href: '/options',
|
||||
icon: Settings
|
||||
}] : []),
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -82,13 +103,14 @@ const Layout = ({ children }) => {
|
||||
|
||||
if (path === '/') return 'Dashboard'
|
||||
if (path === '/hosts') return 'Hosts'
|
||||
if (path === '/host-groups') return 'Host Groups'
|
||||
if (path === '/packages') return 'Packages'
|
||||
if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories'
|
||||
if (path === '/services') return 'Services'
|
||||
if (path === '/docker') return 'Docker'
|
||||
if (path === '/users') return 'Users'
|
||||
if (path === '/permissions') return 'Permissions'
|
||||
if (path === '/settings') return 'Settings'
|
||||
if (path === '/options') return 'Options'
|
||||
if (path === '/profile') return 'My Profile'
|
||||
if (path.startsWith('/hosts/')) return 'Host Details'
|
||||
if (path.startsWith('/packages/')) return 'Package Details'
|
||||
@@ -267,7 +289,7 @@ const Layout = ({ children }) => {
|
||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5 text-white" />
|
||||
<ChevronRight className="h-5 w-5 text-secondary-700 dark:text-white" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
@@ -280,7 +302,7 @@ const Layout = ({ children }) => {
|
||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5 text-white" />
|
||||
<ChevronLeft className="h-5 w-5 text-secondary-700 dark:text-white" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -360,13 +382,18 @@ const Layout = ({ children }) => {
|
||||
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'} ${
|
||||
} ${sidebarCollapsed ? 'justify-center p-2 relative' : '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' : ''}`} />
|
||||
<div className={`flex items-center ${sidebarCollapsed ? 'justify-center' : ''}`}>
|
||||
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
{sidebarCollapsed && subItem.showUpgradeIcon && (
|
||||
<UpgradeNotificationIcon className="h-3 w-3 absolute -top-1 -right-1" />
|
||||
)}
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{subItem.name}
|
||||
@@ -375,6 +402,9 @@ const Layout = ({ children }) => {
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
{subItem.showUpgradeIcon && (
|
||||
<UpgradeNotificationIcon className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
@@ -436,17 +466,22 @@ const Layout = ({ children }) => {
|
||||
</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>
|
||||
<div className="px-2 py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-x-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<Clock className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate">Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0"
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
{versionInfo && (
|
||||
<span className="text-xs text-secondary-400 dark:text-secondary-500 flex-shrink-0">
|
||||
v{versionInfo.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -473,7 +508,7 @@ const Layout = ({ children }) => {
|
||||
</button>
|
||||
{/* Updated info for collapsed sidebar */}
|
||||
{stats && (
|
||||
<div className="flex justify-center py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex flex-col items-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"
|
||||
@@ -481,6 +516,11 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
{versionInfo && (
|
||||
<span className="text-xs text-secondary-400 dark:text-secondary-500 mt-1">
|
||||
v{versionInfo.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
15
frontend/src/components/UpgradeNotificationIcon.jsx
Normal file
15
frontend/src/components/UpgradeNotificationIcon.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import { ArrowUpCircle } from 'lucide-react'
|
||||
|
||||
const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
|
||||
if (!show) return null
|
||||
|
||||
return (
|
||||
<ArrowUpCircle
|
||||
className={`${className} text-red-500 animate-pulse`}
|
||||
title="Update available"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpgradeNotificationIcon
|
||||
35
frontend/src/contexts/UpdateNotificationContext.jsx
Normal file
35
frontend/src/contexts/UpdateNotificationContext.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
|
||||
const UpdateNotificationContext = createContext()
|
||||
|
||||
export const useUpdateNotification = () => {
|
||||
const context = useContext(UpdateNotificationContext)
|
||||
if (!context) {
|
||||
throw new Error('useUpdateNotification must be used within an UpdateNotificationProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const UpdateNotificationProvider = ({ children }) => {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
const [updateInfo, setUpdateInfo] = useState(null)
|
||||
|
||||
const dismissNotification = () => {
|
||||
setUpdateAvailable(false)
|
||||
setUpdateInfo(null)
|
||||
}
|
||||
|
||||
const value = {
|
||||
updateAvailable,
|
||||
updateInfo,
|
||||
dismissNotification,
|
||||
isLoading: false,
|
||||
error: null
|
||||
}
|
||||
|
||||
return (
|
||||
<UpdateNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</UpdateNotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
Shield,
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
Clock
|
||||
Clock,
|
||||
WifiOff
|
||||
} from 'lucide-react'
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
|
||||
import { Pie, Bar } from 'react-chartjs-2'
|
||||
@@ -46,6 +47,10 @@ const Dashboard = () => {
|
||||
navigate('/hosts?filter=inactive')
|
||||
}
|
||||
|
||||
const handleOfflineHostsClick = () => {
|
||||
navigate('/hosts?filter=offline')
|
||||
}
|
||||
|
||||
const handleOSDistributionClick = () => {
|
||||
navigate('/hosts')
|
||||
}
|
||||
@@ -151,7 +156,7 @@ const Dashboard = () => {
|
||||
const getCardType = (cardId) => {
|
||||
if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates'].includes(cardId)) {
|
||||
return 'stats';
|
||||
} else if (['osDistribution', 'updateStatus', 'packagePriority'].includes(cardId)) {
|
||||
} else if (['osDistribution', 'osDistributionBar', 'updateStatus', 'packagePriority'].includes(cardId)) {
|
||||
return 'charts';
|
||||
} else if (['erroredHosts', 'quickStats'].includes(cardId)) {
|
||||
return 'fullwidth';
|
||||
@@ -295,6 +300,45 @@ const Dashboard = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'offlineHosts':
|
||||
return (
|
||||
<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.offlineHosts > 0
|
||||
? 'bg-warning-50 border-warning-200'
|
||||
: 'bg-success-50 border-success-200'
|
||||
}`}
|
||||
onClick={handleOfflineHostsClick}
|
||||
>
|
||||
<div className="flex">
|
||||
<WifiOff className={`h-5 w-5 ${
|
||||
stats.cards.offlineHosts > 0 ? 'text-warning-400' : 'text-success-400'
|
||||
}`} />
|
||||
<div className="ml-3">
|
||||
{stats.cards.offlineHosts > 0 ? (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-warning-800">
|
||||
{stats.cards.offlineHosts} host{stats.cards.offlineHosts > 1 ? 's' : ''} offline/stale
|
||||
</h3>
|
||||
<p className="text-sm text-warning-700 mt-1">
|
||||
These hosts haven't reported in {formatUpdateIntervalThreshold() * 3}+ minutes.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-success-800">
|
||||
All hosts are online
|
||||
</h3>
|
||||
<p className="text-sm text-success-700 mt-1">
|
||||
No hosts are offline or stale.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'osDistribution':
|
||||
return (
|
||||
<div
|
||||
@@ -308,6 +352,19 @@ const Dashboard = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'osDistributionBar':
|
||||
return (
|
||||
<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">
|
||||
<Bar data={osBarChartData} options={barChartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'updateStatus':
|
||||
return (
|
||||
<div
|
||||
@@ -414,6 +471,40 @@ const Dashboard = () => {
|
||||
},
|
||||
}
|
||||
|
||||
const barChartOptions = {
|
||||
responsive: true,
|
||||
indexAxis: 'y', // Make the chart horizontal
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: isDark ? '#ffffff' : '#374151',
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: isDark ? '#374151' : '#e5e7eb'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: isDark ? '#ffffff' : '#374151',
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: isDark ? '#374151' : '#e5e7eb'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const osChartData = {
|
||||
labels: stats.charts.osDistribution.map(item => item.name),
|
||||
datasets: [
|
||||
@@ -433,6 +524,28 @@ const Dashboard = () => {
|
||||
],
|
||||
}
|
||||
|
||||
const osBarChartData = {
|
||||
labels: stats.charts.osDistribution.map(item => item.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Hosts',
|
||||
data: stats.charts.osDistribution.map(item => item.count),
|
||||
backgroundColor: [
|
||||
'#3B82F6', // Blue
|
||||
'#10B981', // Green
|
||||
'#F59E0B', // Yellow
|
||||
'#EF4444', // Red
|
||||
'#8B5CF6', // Purple
|
||||
'#06B6D4', // Cyan
|
||||
],
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#374151' : '#ffffff',
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const updateStatusChartData = {
|
||||
labels: stats.charts.updateStatusDistribution.map(item => item.name),
|
||||
datasets: [
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Lock, User, AlertCircle } from 'lucide-react'
|
||||
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { authAPI } from '../utils/api'
|
||||
|
||||
const Login = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
const [tfaData, setTfaData] = useState({
|
||||
token: ''
|
||||
})
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [requiresTfa, setRequiresTfa] = useState(false)
|
||||
const [tfaUsername, setTfaUsername] = useState('')
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
@@ -21,16 +27,52 @@ const Login = () => {
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await login(formData.username, formData.password)
|
||||
const response = await authAPI.login(formData.username, formData.password)
|
||||
|
||||
if (result.success) {
|
||||
if (response.data.requiresTfa) {
|
||||
setRequiresTfa(true)
|
||||
setTfaUsername(formData.username)
|
||||
setError('')
|
||||
} else {
|
||||
// Regular login successful
|
||||
const result = await login(formData.username, formData.password)
|
||||
if (result.success) {
|
||||
navigate('/')
|
||||
} else {
|
||||
setError(result.error || 'Login failed')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Login failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTfaSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token)
|
||||
|
||||
if (response.data && response.data.token) {
|
||||
// Store token and user data
|
||||
localStorage.setItem('token', response.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user))
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/')
|
||||
} else {
|
||||
setError(result.error || 'Login failed')
|
||||
setError('TFA verification failed - invalid response')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error occurred')
|
||||
console.error('TFA verification error:', err)
|
||||
const errorMessage = err.response?.data?.error || err.message || 'TFA verification failed'
|
||||
setError(errorMessage)
|
||||
// Clear the token input for security
|
||||
setTfaData({ token: '' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -43,6 +85,23 @@ const Login = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleTfaInputChange = (e) => {
|
||||
setTfaData({
|
||||
...tfaData,
|
||||
[e.target.name]: e.target.value.replace(/\D/g, '').slice(0, 6)
|
||||
})
|
||||
// Clear error when user starts typing
|
||||
if (error) {
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setRequiresTfa(false)
|
||||
setTfaData({ token: '' })
|
||||
setError('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
@@ -58,7 +117,8 @@ const Login = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{!requiresTfa ? (
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
|
||||
@@ -150,6 +210,83 @@ const Login = () => {
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
|
||||
<Smartphone className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium text-secondary-900">
|
||||
Two-Factor Authentication
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-secondary-600">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="token" className="block text-sm font-medium text-secondary-700">
|
||||
Verification Code
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="token"
|
||||
name="token"
|
||||
type="text"
|
||||
required
|
||||
value={tfaData.token}
|
||||
onChange={handleTfaInputChange}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
|
||||
placeholder="000000"
|
||||
maxLength="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || tfaData.token.length !== 6}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Verifying...
|
||||
</div>
|
||||
) : (
|
||||
'Verify Code'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToLogin}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
Don't have access to your authenticator? Use a backup code.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
570
frontend/src/pages/Options.jsx
Normal file
570
frontend/src/pages/Options.jsx
Normal file
@@ -0,0 +1,570 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Server,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
CheckCircle
|
||||
} from 'lucide-react'
|
||||
import { hostGroupsAPI } from '../utils/api'
|
||||
|
||||
const Options = () => {
|
||||
const [activeTab, setActiveTab] = useState('hostgroups')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [selectedGroup, setSelectedGroup] = useState(null)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [groupToDelete, setGroupToDelete] = useState(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{ id: 'hostgroups', name: 'Host Groups', icon: Users },
|
||||
{ id: 'notifications', name: 'Notifications', icon: AlertTriangle, comingSoon: true }
|
||||
]
|
||||
|
||||
// Fetch host groups
|
||||
const { data: hostGroups, isLoading, error } = useQuery({
|
||||
queryKey: ['hostGroups'],
|
||||
queryFn: () => hostGroupsAPI.list().then(res => res.data),
|
||||
})
|
||||
|
||||
// Create host group mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => hostGroupsAPI.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowCreateModal(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create host group:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Update host group mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update host group:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Delete host group mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => hostGroupsAPI.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete host group:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleCreate = (data) => {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
|
||||
const handleEdit = (group) => {
|
||||
setSelectedGroup(group)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
|
||||
const handleUpdate = (data) => {
|
||||
updateMutation.mutate({ id: selectedGroup.id, data })
|
||||
}
|
||||
|
||||
const handleDeleteClick = (group) => {
|
||||
setGroupToDelete(group)
|
||||
setShowDeleteModal(true)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
deleteMutation.mutate(groupToDelete.id)
|
||||
}
|
||||
|
||||
const renderHostGroupsTab = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Error loading host groups
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || 'Failed to load host groups'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Host Groups
|
||||
</h2>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
Organize your hosts into logical groups for better management
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Host Groups Grid */}
|
||||
{hostGroups && hostGroups.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{hostGroups.map((group) => (
|
||||
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
{group.name}
|
||||
</h3>
|
||||
{group.description && (
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
|
||||
{group.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(group)}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
|
||||
title="Edit group"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(group)}
|
||||
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
|
||||
title="Delete group"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<div className="flex items-center gap-1">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
No host groups yet
|
||||
</h3>
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Create your first host group to organize your hosts
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderComingSoonTab = (tabName) => (
|
||||
<div className="text-center py-12">
|
||||
<SettingsIcon className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
{tabName} Coming Soon
|
||||
</h3>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
This feature is currently under development and will be available in a future update.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Options
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mt-1">
|
||||
Configure PatchMon parameters and user preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.name}
|
||||
{tab.comingSoon && (
|
||||
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="mt-6">
|
||||
{activeTab === 'hostgroups' && renderHostGroupsTab()}
|
||||
{activeTab === 'notifications' && renderComingSoonTab('Notifications')}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateHostGroupModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreate}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && selectedGroup && (
|
||||
<EditHostGroupModal
|
||||
group={selectedGroup}
|
||||
onClose={() => {
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && groupToDelete && (
|
||||
<DeleteHostGroupModal
|
||||
group={groupToDelete}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
}}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create Host Group Modal
|
||||
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#3B82F6'
|
||||
})
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
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 p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Create Host Group
|
||||
</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">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="e.g., Production Servers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="Optional description for this group"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit Host Group Modal
|
||||
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: group.name,
|
||||
description: group.description || '',
|
||||
color: group.color || '#3B82F6'
|
||||
})
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
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 p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Edit Host Group
|
||||
</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">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="e.g., Production Servers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="Optional description for this group"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Delete Confirmation Modal
|
||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
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 p-6 w-full max-w-md">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Delete Host Group
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
This action cannot be undone
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-200">
|
||||
Are you sure you want to delete the host group{' '}
|
||||
<span className="font-semibold">"{group.name}"</span>?
|
||||
</p>
|
||||
{group._count.hosts > 0 && (
|
||||
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||
<p className="text-sm text-warning-800">
|
||||
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
|
||||
You must move or remove these hosts before deleting the group.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="btn-danger"
|
||||
disabled={isLoading || group._count.hosts > 0}
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete Group'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Options
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
@@ -13,8 +14,15 @@ import {
|
||||
AlertCircle,
|
||||
Sun,
|
||||
Moon,
|
||||
Settings
|
||||
Settings,
|
||||
Smartphone,
|
||||
QrCode,
|
||||
Copy,
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
import { tfaAPI } from '../utils/api'
|
||||
|
||||
const Profile = () => {
|
||||
const { user, updateProfile, changePassword } = useAuth()
|
||||
@@ -111,6 +119,7 @@ const Profile = () => {
|
||||
const tabs = [
|
||||
{ id: 'profile', name: 'Profile Information', icon: User },
|
||||
{ id: 'password', name: 'Change Password', icon: Key },
|
||||
{ id: 'tfa', name: 'Multi-Factor Authentication', icon: Smartphone },
|
||||
{ id: 'preferences', name: 'Preferences', icon: Settings }
|
||||
]
|
||||
|
||||
@@ -357,6 +366,11 @@ const Profile = () => {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Multi-Factor Authentication Tab */}
|
||||
{activeTab === 'tfa' && (
|
||||
<TfaTab />
|
||||
)}
|
||||
|
||||
{/* Preferences Tab */}
|
||||
{activeTab === 'preferences' && (
|
||||
<div className="space-y-6">
|
||||
@@ -411,4 +425,399 @@ const Profile = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// TFA Tab Component
|
||||
const TfaTab = () => {
|
||||
const [setupStep, setSetupStep] = useState('status') // 'status', 'setup', 'verify', 'backup-codes'
|
||||
const [verificationToken, setVerificationToken] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [backupCodes, setBackupCodes] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [message, setMessage] = useState({ type: '', text: '' })
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch TFA status
|
||||
const { data: tfaStatus, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['tfaStatus'],
|
||||
queryFn: () => tfaAPI.status().then(res => res.data),
|
||||
})
|
||||
|
||||
// Setup TFA mutation
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: () => tfaAPI.setup().then(res => res.data),
|
||||
onSuccess: (data) => {
|
||||
setSetupStep('setup')
|
||||
setMessage({ type: 'info', text: 'Scan the QR code with your authenticator app and enter the verification code below.' })
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to setup TFA' })
|
||||
}
|
||||
})
|
||||
|
||||
// Verify setup mutation
|
||||
const verifyMutation = useMutation({
|
||||
mutationFn: (data) => tfaAPI.verifySetup(data).then(res => res.data),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backupCodes)
|
||||
setSetupStep('backup-codes')
|
||||
setMessage({ type: 'success', text: 'Two-factor authentication has been enabled successfully!' })
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to verify TFA setup' })
|
||||
}
|
||||
})
|
||||
|
||||
// Disable TFA mutation
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: (data) => tfaAPI.disable(data).then(res => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['tfaStatus'])
|
||||
setSetupStep('status')
|
||||
setMessage({ type: 'success', text: 'Two-factor authentication has been disabled successfully!' })
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to disable TFA' })
|
||||
}
|
||||
})
|
||||
|
||||
// Regenerate backup codes mutation
|
||||
const regenerateBackupCodesMutation = useMutation({
|
||||
mutationFn: () => tfaAPI.regenerateBackupCodes().then(res => res.data),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backupCodes)
|
||||
setMessage({ type: 'success', text: 'Backup codes have been regenerated successfully!' })
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to regenerate backup codes' })
|
||||
}
|
||||
})
|
||||
|
||||
const handleSetup = () => {
|
||||
setupMutation.mutate()
|
||||
}
|
||||
|
||||
const handleVerify = (e) => {
|
||||
e.preventDefault()
|
||||
if (verificationToken.length !== 6) {
|
||||
setMessage({ type: 'error', text: 'Please enter a 6-digit verification code' })
|
||||
return
|
||||
}
|
||||
verifyMutation.mutate({ token: verificationToken })
|
||||
}
|
||||
|
||||
const handleDisable = (e) => {
|
||||
e.preventDefault()
|
||||
if (!password) {
|
||||
setMessage({ type: 'error', text: 'Please enter your password to disable TFA' })
|
||||
return
|
||||
}
|
||||
disableMutation.mutate({ password })
|
||||
}
|
||||
|
||||
const handleRegenerateBackupCodes = () => {
|
||||
regenerateBackupCodesMutation.mutate()
|
||||
}
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setMessage({ type: 'success', text: 'Copied to clipboard!' })
|
||||
}
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
const content = `PatchMon Backup Codes\n\n${backupCodes.map((code, index) => `${index + 1}. ${code}`).join('\n')}\n\nKeep these codes safe! Each code can only be used once.`
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'patchmon-backup-codes.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Multi-Factor Authentication</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Add an extra layer of security to your account by enabling two-factor authentication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Message */}
|
||||
{message.text && (
|
||||
<div className={`rounded-md p-4 ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700'
|
||||
: message.type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700'
|
||||
: 'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700'
|
||||
}`}>
|
||||
<div className="flex">
|
||||
{message.type === 'success' ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
|
||||
) : message.type === 'error' ? (
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-blue-400 dark:text-blue-300" />
|
||||
)}
|
||||
<div className="ml-3">
|
||||
<p className={`text-sm font-medium ${
|
||||
message.type === 'success' ? 'text-green-800 dark:text-green-200' :
|
||||
message.type === 'error' ? 'text-red-800 dark:text-red-200' :
|
||||
'text-blue-800 dark:text-blue-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TFA Status */}
|
||||
{setupStep === 'status' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`p-2 rounded-full ${tfaStatus?.enabled ? 'bg-green-100 dark:bg-green-900' : 'bg-secondary-100 dark:bg-secondary-700'}`}>
|
||||
<Smartphone className={`h-6 w-6 ${tfaStatus?.enabled ? 'text-green-600 dark:text-green-400' : 'text-secondary-600 dark:text-secondary-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
{tfaStatus?.enabled ? 'Two-Factor Authentication Enabled' : 'Two-Factor Authentication Disabled'}
|
||||
</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
{tfaStatus?.enabled
|
||||
? 'Your account is protected with two-factor authentication.'
|
||||
: 'Add an extra layer of security to your account.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{tfaStatus?.enabled ? (
|
||||
<button
|
||||
onClick={() => setSetupStep('disable')}
|
||||
className="btn-outline text-danger-600 border-danger-300 hover:bg-danger-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Disable TFA
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSetup}
|
||||
disabled={setupMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Smartphone className="h-4 w-4 mr-2" />
|
||||
{setupMutation.isPending ? 'Setting up...' : 'Enable TFA'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tfaStatus?.enabled && (
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Backup Codes</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
Use these backup codes to access your account if you lose your authenticator device.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
disabled={regenerateBackupCodesMutation.isPending}
|
||||
className="btn-outline"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${regenerateBackupCodesMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{regenerateBackupCodesMutation.isPending ? 'Regenerating...' : 'Regenerate Codes'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TFA Setup */}
|
||||
{setupStep === 'setup' && setupMutation.data && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Setup Two-Factor Authentication</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<img
|
||||
src={setupMutation.data.qrCode}
|
||||
alt="QR Code"
|
||||
className="mx-auto h-48 w-48 border border-secondary-200 dark:border-secondary-600 rounded-lg"
|
||||
/>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-2">
|
||||
Scan this QR code with your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white mb-2">Manual Entry Key:</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<code className="flex-1 bg-white dark:bg-secondary-800 px-3 py-2 rounded border text-sm font-mono">
|
||||
{setupMutation.data.manualEntryKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(setupMutation.data.manualEntryKey)}
|
||||
className="p-2 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => setSetupStep('verify')}
|
||||
className="btn-primary"
|
||||
>
|
||||
Continue to Verification
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TFA Verification */}
|
||||
{setupStep === 'verify' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Verify Setup</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
Enter the 6-digit code from your authenticator app to complete the setup.
|
||||
</p>
|
||||
<form onSubmit={handleVerify} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Verification Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={verificationToken}
|
||||
onChange={(e) => setVerificationToken(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white text-center text-lg font-mono tracking-widest"
|
||||
maxLength="6"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={verifyMutation.isPending || verificationToken.length !== 6}
|
||||
className="btn-primary"
|
||||
>
|
||||
{verifyMutation.isPending ? 'Verifying...' : 'Verify & Enable'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupStep('status')}
|
||||
className="btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup Codes */}
|
||||
{setupStep === 'backup-codes' && backupCodes.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Backup Codes</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
Save these backup codes in a safe place. Each code can only be used once.
|
||||
</p>
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg mb-4">
|
||||
<div className="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
{backupCodes.map((code, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-1">
|
||||
<span className="text-secondary-600 dark:text-secondary-400">{index + 1}.</span>
|
||||
<span className="text-secondary-900 dark:text-white">{code}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={downloadBackupCodes}
|
||||
className="btn-outline"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download Codes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSetupStep('status')
|
||||
queryClient.invalidateQueries(['tfaStatus'])
|
||||
}}
|
||||
className="btn-primary"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable TFA */}
|
||||
{setupStep === 'disable' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Disable Two-Factor Authentication</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
Enter your password to disable two-factor authentication.
|
||||
</p>
|
||||
<form onSubmit={handleDisable} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disableMutation.isPending || !password}
|
||||
className="btn-danger"
|
||||
>
|
||||
{disableMutation.isPending ? 'Disabling...' : 'Disable TFA'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupStep('status')}
|
||||
className="btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Profile
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Save, Server, Globe, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { settingsAPI, agentVersionAPI, versionAPI } from '../utils/api';
|
||||
import { useUpdateNotification } from '../contexts/UpdateNotificationContext';
|
||||
import UpgradeNotificationIcon from '../components/UpgradeNotificationIcon';
|
||||
|
||||
const Settings = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -12,6 +14,7 @@ const Settings = () => {
|
||||
updateInterval: 60,
|
||||
autoUpdate: false,
|
||||
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: 'public',
|
||||
sshKeyPath: '',
|
||||
useCustomSshKey: false
|
||||
});
|
||||
@@ -21,12 +24,15 @@ const Settings = () => {
|
||||
// Tab management
|
||||
const [activeTab, setActiveTab] = useState('server');
|
||||
|
||||
// Get update notification state
|
||||
const { updateAvailable } = useUpdateNotification();
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{ id: 'server', name: 'Server Configuration', icon: Server },
|
||||
{ id: 'frontend', name: 'Frontend Configuration', icon: Globe },
|
||||
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon },
|
||||
{ id: 'version', name: 'Server Version', icon: Code }
|
||||
{ id: 'version', name: 'Server Version', icon: Code, showUpgradeIcon: updateAvailable }
|
||||
];
|
||||
|
||||
// Agent version management state
|
||||
@@ -76,6 +82,7 @@ const Settings = () => {
|
||||
updateInterval: settings.updateInterval || 60,
|
||||
autoUpdate: settings.autoUpdate || false,
|
||||
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: settings.repositoryType || 'public',
|
||||
sshKeyPath: settings.sshKeyPath || '',
|
||||
useCustomSshKey: !!settings.sshKeyPath
|
||||
};
|
||||
@@ -107,6 +114,7 @@ const Settings = () => {
|
||||
updateInterval: data.settings?.updateInterval || data.updateInterval || 60,
|
||||
autoUpdate: data.settings?.autoUpdate || data.autoUpdate || false,
|
||||
githubRepoUrl: data.settings?.githubRepoUrl || data.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: data.settings?.repositoryType || data.repositoryType || 'public',
|
||||
sshKeyPath: data.settings?.sshKeyPath || data.sshKeyPath || '',
|
||||
useCustomSshKey: !!(data.settings?.sshKeyPath || data.sshKeyPath)
|
||||
});
|
||||
@@ -301,6 +309,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);
|
||||
@@ -368,6 +377,9 @@ const Settings = () => {
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.name}
|
||||
{tab.showUpgradeIcon && (
|
||||
<UpgradeNotificationIcon className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -774,13 +786,52 @@ const Settings = () => {
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
Repository Type
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
id="repo-public"
|
||||
name="repositoryType"
|
||||
value="public"
|
||||
checked={formData.repositoryType === 'public'}
|
||||
onChange={(e) => handleInputChange('repositoryType', e.target.value)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
|
||||
/>
|
||||
<label htmlFor="repo-public" className="ml-2 text-sm text-secondary-700 dark:text-secondary-200">
|
||||
Public Repository (uses GitHub API - no authentication required)
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
id="repo-private"
|
||||
name="repositoryType"
|
||||
value="private"
|
||||
checked={formData.repositoryType === 'private'}
|
||||
onChange={(e) => handleInputChange('repositoryType', e.target.value)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
|
||||
/>
|
||||
<label htmlFor="repo-private" className="ml-2 text-sm text-secondary-700 dark:text-secondary-200">
|
||||
Private Repository (uses SSH with deploy key)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Choose whether your repository is public or private to determine the appropriate access method.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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'}
|
||||
value={formData.githubRepoUrl || ''}
|
||||
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"
|
||||
@@ -790,25 +841,26 @@ const Settings = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useCustomSshKey"
|
||||
checked={formData.useCustomSshKey}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
handleInputChange('useCustomSshKey', checked);
|
||||
if (!checked) {
|
||||
handleInputChange('sshKeyPath', '');
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="useCustomSshKey" className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
Set custom SSH key path
|
||||
</label>
|
||||
</div>
|
||||
{formData.repositoryType === 'private' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useCustomSshKey"
|
||||
checked={formData.useCustomSshKey}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
handleInputChange('useCustomSshKey', checked);
|
||||
if (!checked) {
|
||||
handleInputChange('sshKeyPath', '');
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="useCustomSshKey" className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
Set custom SSH key path
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.useCustomSshKey && (
|
||||
<div>
|
||||
@@ -866,7 +918,8 @@ const Settings = () => {
|
||||
Using auto-detection for SSH key location
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
|
||||
@@ -31,9 +31,17 @@ api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Handle unauthorized
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
// Don't redirect if we're on the login page or if it's a TFA verification error
|
||||
const currentPath = window.location.pathname
|
||||
const isTfaError = error.config?.url?.includes('/verify-tfa')
|
||||
|
||||
if (currentPath !== '/login' && !isTfaError) {
|
||||
// Handle unauthorized
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('permissions')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
@@ -185,6 +193,22 @@ export const versionAPI = {
|
||||
testSshKey: (data) => api.post('/version/test-ssh-key', data),
|
||||
}
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
login: (username, password) => api.post('/auth/login', { username, password }),
|
||||
verifyTfa: (username, token) => api.post('/auth/verify-tfa', { username, token }),
|
||||
}
|
||||
|
||||
// TFA API
|
||||
export const tfaAPI = {
|
||||
setup: () => api.get('/tfa/setup'),
|
||||
verifySetup: (data) => api.post('/tfa/verify-setup', data),
|
||||
disable: (data) => api.post('/tfa/disable', data),
|
||||
status: () => api.get('/tfa/status'),
|
||||
regenerateBackupCodes: () => api.post('/tfa/regenerate-backup-codes'),
|
||||
verify: (data) => api.post('/tfa/verify', data),
|
||||
}
|
||||
|
||||
export const formatRelativeTime = (date) => {
|
||||
const now = new Date()
|
||||
const diff = now - new Date(date)
|
||||
|
||||
Reference in New Issue
Block a user