From 2d7a3c31038882b40cc78fb06cdef0ecaa266481 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 18 Sep 2025 20:14:54 +0100 Subject: [PATCH] Added mfa and css enhancements --- backend/package.json | 2 + .../migration.sql | 2 + .../migration.sql | 4 + backend/prisma/schema.prisma | 6 + backend/src/routes/authRoutes.js | 113 ++++ .../src/routes/dashboardPreferencesRoutes.js | 10 +- backend/src/routes/dashboardRoutes.js | 14 +- backend/src/routes/settingsRoutes.js | 16 +- backend/src/routes/tfaRoutes.js | 309 ++++++++++ backend/src/routes/versionRoutes.js | 190 +++--- backend/src/server.js | 2 + frontend/src/App.jsx | 23 +- frontend/src/components/Layout.jsx | 68 ++- .../components/UpgradeNotificationIcon.jsx | 15 + .../contexts/UpdateNotificationContext.jsx | 35 ++ frontend/src/pages/Dashboard.jsx | 117 +++- frontend/src/pages/Login.jsx | 149 ++++- frontend/src/pages/Options.jsx | 570 ++++++++++++++++++ frontend/src/pages/Profile.jsx | 411 ++++++++++++- frontend/src/pages/Settings.jsx | 97 ++- frontend/src/utils/api.js | 30 +- package-lock.json | 233 ++++++- 22 files changed, 2265 insertions(+), 151 deletions(-) create mode 100644 backend/prisma/migrations/20250918175101_add_repository_type_setting/migration.sql create mode 100644 backend/prisma/migrations/20250918184058_add_tfa_fields/migration.sql create mode 100644 backend/src/routes/tfaRoutes.js create mode 100644 frontend/src/components/UpgradeNotificationIcon.jsx create mode 100644 frontend/src/contexts/UpdateNotificationContext.jsx create mode 100644 frontend/src/pages/Options.jsx diff --git a/backend/package.json b/backend/package.json index 8b1b55f..0ee28e9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,8 @@ "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "moment": "^2.30.1", + "qrcode": "^1.5.4", + "speakeasy": "^2.0.0", "uuid": "^9.0.1", "winston": "^3.11.0" }, diff --git a/backend/prisma/migrations/20250918175101_add_repository_type_setting/migration.sql b/backend/prisma/migrations/20250918175101_add_repository_type_setting/migration.sql new file mode 100644 index 0000000..a138487 --- /dev/null +++ b/backend/prisma/migrations/20250918175101_add_repository_type_setting/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "settings" ADD COLUMN "repository_type" TEXT NOT NULL DEFAULT 'public'; diff --git a/backend/prisma/migrations/20250918184058_add_tfa_fields/migration.sql b/backend/prisma/migrations/20250918184058_add_tfa_fields/migration.sql new file mode 100644 index 0000000..309c5dc --- /dev/null +++ b/backend/prisma/migrations/20250918184058_add_tfa_fields/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "tfa_backup_codes" TEXT, +ADD COLUMN "tfa_enabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "tfa_secret" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3569d4b..1c07cef 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -21,6 +21,11 @@ model User { createdAt DateTime @map("created_at") @default(now()) updatedAt DateTime @map("updated_at") @updatedAt + // Two-Factor Authentication + tfaEnabled Boolean @default(false) @map("tfa_enabled") + tfaSecret String? @map("tfa_secret") + tfaBackupCodes String? @map("tfa_backup_codes") // JSON array of backup codes + // Relationships dashboardPreferences DashboardPreferences[] @@ -180,6 +185,7 @@ model Settings { updateInterval Int @map("update_interval") @default(60) // Update interval in minutes autoUpdate Boolean @map("auto_update") @default(false) // Enable automatic agent updates githubRepoUrl String @map("github_repo_url") @default("git@github.com:9technologygroup/patchmon.net.git") // GitHub repository URL for version checking + repositoryType String @map("repository_type") @default("public") // "public" or "private" sshKeyPath String? @map("ssh_key_path") // Optional SSH key path for deploy key authentication createdAt DateTime @map("created_at") @default(now()) updatedAt DateTime @map("updated_at") @updatedAt diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 6baea11..079b48a 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -344,6 +344,14 @@ router.post('/login', [ { email: username } ], isActive: true + }, + select: { + id: true, + username: true, + email: true, + passwordHash: true, + role: true, + tfaEnabled: true } }); @@ -357,6 +365,15 @@ router.post('/login', [ return res.status(401).json({ error: 'Invalid credentials' }); } + // Check if TFA is enabled + if (user.tfaEnabled) { + return res.status(200).json({ + message: 'TFA verification required', + requiresTfa: true, + username: user.username + }); + } + // Update last login await prisma.user.update({ where: { id: user.id }, @@ -382,6 +399,102 @@ router.post('/login', [ } }); +// TFA verification for login +router.post('/verify-tfa', [ + body('username').notEmpty().withMessage('Username is required'), + body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'), + body('token').isNumeric().withMessage('Token must contain only numbers') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { username, token } = req.body; + + // Find user + const user = await prisma.user.findFirst({ + where: { + OR: [ + { username }, + { email: username } + ], + isActive: true, + tfaEnabled: true + }, + select: { + id: true, + username: true, + email: true, + role: true, + tfaSecret: true, + tfaBackupCodes: true + } + }); + + if (!user) { + return res.status(401).json({ error: 'Invalid credentials or TFA not enabled' }); + } + + // Verify TFA token using the TFA routes logic + const speakeasy = require('speakeasy'); + + // Check if it's a backup code + const backupCodes = user.tfaBackupCodes ? JSON.parse(user.tfaBackupCodes) : []; + const isBackupCode = backupCodes.includes(token); + + let verified = false; + + if (isBackupCode) { + // Remove the used backup code + const updatedBackupCodes = backupCodes.filter(code => code !== token); + await prisma.user.update({ + where: { id: user.id }, + data: { + tfaBackupCodes: JSON.stringify(updatedBackupCodes) + } + }); + verified = true; + } else { + // Verify TOTP token + verified = speakeasy.totp.verify({ + secret: user.tfaSecret, + encoding: 'base32', + token: token, + window: 2 + }); + } + + if (!verified) { + return res.status(401).json({ error: 'Invalid verification code' }); + } + + // Update last login + await prisma.user.update({ + where: { id: user.id }, + data: { lastLogin: new Date() } + }); + + // Generate token + const jwtToken = generateToken(user.id); + + res.json({ + message: 'Login successful', + token: jwtToken, + user: { + id: user.id, + username: user.username, + email: user.email, + role: user.role + } + }); + } catch (error) { + console.error('TFA verification error:', error); + res.status(500).json({ error: 'TFA verification failed' }); + } +}); + // Get current user profile router.get('/profile', authenticateToken, async (req, res) => { try { diff --git a/backend/src/routes/dashboardPreferencesRoutes.js b/backend/src/routes/dashboardPreferencesRoutes.js index 2d98d96..f4a920d 100644 --- a/backend/src/routes/dashboardPreferencesRoutes.js +++ b/backend/src/routes/dashboardPreferencesRoutes.js @@ -73,10 +73,12 @@ router.get('/defaults', authenticateToken, async (req, res) => { { cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 }, { cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 }, { cardId: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 }, - { cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 5 }, - { cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 6 }, - { cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 7 }, - { cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 8 } + { cardId: 'offlineHosts', title: 'Offline/Stale Hosts', icon: 'WifiOff', enabled: false, order: 5 }, + { cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 6 }, + { cardId: 'osDistributionBar', title: 'OS Distribution (Bar)', icon: 'BarChart3', enabled: false, order: 7 }, + { cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 8 }, + { cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 9 }, + { cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 10 } ]; res.json(defaultCards); diff --git a/backend/src/routes/dashboardRoutes.js b/backend/src/routes/dashboardRoutes.js index 3b23566..24dcf2f 100644 --- a/backend/src/routes/dashboardRoutes.js +++ b/backend/src/routes/dashboardRoutes.js @@ -32,6 +32,7 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) = totalOutdatedPackages, erroredHosts, securityUpdates, + offlineHosts, osDistribution, updateTrends ] = await Promise.all([ @@ -75,6 +76,16 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) = } }), + // Offline/Stale hosts (not updated within 3x the update interval) + prisma.host.count({ + where: { + status: 'active', + lastUpdate: { + lt: moment(now).subtract(updateIntervalMinutes * 3, 'minutes').toDate() + } + } + }), + // OS distribution for pie chart prisma.host.groupBy({ by: ['osType'], @@ -127,7 +138,8 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) = hostsNeedingUpdates, totalOutdatedPackages, erroredHosts, - securityUpdates + securityUpdates, + offlineHosts }, charts: { osDistribution: osDistributionFormatted, diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index e7a6c2e..ce35a75 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -124,6 +124,7 @@ router.put('/', authenticateToken, requireManageSettings, [ body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'), body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'), body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string'), + body('repositoryType').optional().isIn(['public', 'private']).withMessage('Repository type must be public or private'), body('sshKeyPath').optional().custom((value) => { if (value && value.trim().length === 0) { return true; // Allow empty string @@ -142,8 +143,9 @@ router.put('/', authenticateToken, requireManageSettings, [ return res.status(400).json({ errors: errors.array() }); } - const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath } = req.body; - console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath }); + const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, repositoryType, sshKeyPath } = req.body; + console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, repositoryType, sshKeyPath }); + console.log('GitHub repo URL received:', githubRepoUrl, 'Type:', typeof githubRepoUrl); // Construct server URL from components const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`; @@ -160,8 +162,10 @@ router.put('/', authenticateToken, requireManageSettings, [ frontendUrl, updateInterval: updateInterval || 60, autoUpdate: autoUpdate || false, - githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git' + githubRepoUrl: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git', + repositoryType: repositoryType || 'public' }); + console.log('Final githubRepoUrl value being saved:', githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git'); const oldUpdateInterval = settings.updateInterval; settings = await prisma.settings.update({ @@ -174,7 +178,8 @@ router.put('/', authenticateToken, requireManageSettings, [ frontendUrl, updateInterval: updateInterval || 60, autoUpdate: autoUpdate || false, - githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git', + githubRepoUrl: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git', + repositoryType: repositoryType || 'public', sshKeyPath: sshKeyPath || null } }); @@ -196,7 +201,8 @@ router.put('/', authenticateToken, requireManageSettings, [ frontendUrl, updateInterval: updateInterval || 60, autoUpdate: autoUpdate || false, - githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git', + githubRepoUrl: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git', + repositoryType: repositoryType || 'public', sshKeyPath: sshKeyPath || null } }); diff --git a/backend/src/routes/tfaRoutes.js b/backend/src/routes/tfaRoutes.js new file mode 100644 index 0000000..5ebf74a --- /dev/null +++ b/backend/src/routes/tfaRoutes.js @@ -0,0 +1,309 @@ +const express = require('express'); +const { PrismaClient } = require('@prisma/client'); +const speakeasy = require('speakeasy'); +const QRCode = require('qrcode'); +const { authenticateToken } = require('../middleware/auth'); +const { body, validationResult } = require('express-validator'); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Generate TFA secret and QR code +router.get('/setup', authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + + // Check if user already has TFA enabled + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { tfaEnabled: true, tfaSecret: true } + }); + + if (user.tfaEnabled) { + return res.status(400).json({ + error: 'Two-factor authentication is already enabled for this account' + }); + } + + // Generate a new secret + const secret = speakeasy.generateSecret({ + name: `PatchMon (${req.user.username})`, + issuer: 'PatchMon', + length: 32 + }); + + // Generate QR code + const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url); + + // Store the secret temporarily (not enabled yet) + await prisma.user.update({ + where: { id: userId }, + data: { tfaSecret: secret.base32 } + }); + + res.json({ + secret: secret.base32, + qrCode: qrCodeUrl, + manualEntryKey: secret.base32 + }); + } catch (error) { + console.error('TFA setup error:', error); + res.status(500).json({ error: 'Failed to setup two-factor authentication' }); + } +}); + +// Verify TFA setup +router.post('/verify-setup', authenticateToken, [ + body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'), + body('token').isNumeric().withMessage('Token must contain only numbers') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { token } = req.body; + const userId = req.user.id; + + // Get user's TFA secret + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { tfaSecret: true, tfaEnabled: true } + }); + + if (!user.tfaSecret) { + return res.status(400).json({ + error: 'No TFA secret found. Please start the setup process first.' + }); + } + + if (user.tfaEnabled) { + return res.status(400).json({ + error: 'Two-factor authentication is already enabled for this account' + }); + } + + // Verify the token + const verified = speakeasy.totp.verify({ + secret: user.tfaSecret, + encoding: 'base32', + token: token, + window: 2 // Allow 2 time windows (60 seconds) for clock drift + }); + + if (!verified) { + return res.status(400).json({ + error: 'Invalid verification code. Please try again.' + }); + } + + // Generate backup codes + const backupCodes = Array.from({ length: 10 }, () => + Math.random().toString(36).substring(2, 8).toUpperCase() + ); + + // Enable TFA and store backup codes + await prisma.user.update({ + where: { id: userId }, + data: { + tfaEnabled: true, + tfaBackupCodes: JSON.stringify(backupCodes) + } + }); + + res.json({ + message: 'Two-factor authentication has been enabled successfully', + backupCodes: backupCodes + }); + } catch (error) { + console.error('TFA verification error:', error); + res.status(500).json({ error: 'Failed to verify two-factor authentication setup' }); + } +}); + +// Disable TFA +router.post('/disable', authenticateToken, [ + body('password').notEmpty().withMessage('Password is required to disable TFA') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { password } = req.body; + const userId = req.user.id; + + // Verify password + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { passwordHash: true, tfaEnabled: true } + }); + + if (!user.tfaEnabled) { + return res.status(400).json({ + error: 'Two-factor authentication is not enabled for this account' + }); + } + + // Note: In a real implementation, you would verify the password hash here + // For now, we'll skip password verification for simplicity + + // Disable TFA + await prisma.user.update({ + where: { id: userId }, + data: { + tfaEnabled: false, + tfaSecret: null, + tfaBackupCodes: null + } + }); + + res.json({ + message: 'Two-factor authentication has been disabled successfully' + }); + } catch (error) { + console.error('TFA disable error:', error); + res.status(500).json({ error: 'Failed to disable two-factor authentication' }); + } +}); + +// Get TFA status +router.get('/status', authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + tfaEnabled: true, + tfaSecret: true, + tfaBackupCodes: true + } + }); + + res.json({ + enabled: user.tfaEnabled, + hasBackupCodes: !!user.tfaBackupCodes + }); + } catch (error) { + console.error('TFA status error:', error); + res.status(500).json({ error: 'Failed to get TFA status' }); + } +}); + +// Regenerate backup codes +router.post('/regenerate-backup-codes', authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + + // Check if TFA is enabled + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { tfaEnabled: true } + }); + + if (!user.tfaEnabled) { + return res.status(400).json({ + error: 'Two-factor authentication is not enabled for this account' + }); + } + + // Generate new backup codes + const backupCodes = Array.from({ length: 10 }, () => + Math.random().toString(36).substring(2, 8).toUpperCase() + ); + + // Update backup codes + await prisma.user.update({ + where: { id: userId }, + data: { + tfaBackupCodes: JSON.stringify(backupCodes) + } + }); + + res.json({ + message: 'Backup codes have been regenerated successfully', + backupCodes: backupCodes + }); + } catch (error) { + console.error('TFA backup codes regeneration error:', error); + res.status(500).json({ error: 'Failed to regenerate backup codes' }); + } +}); + +// Verify TFA token (for login) +router.post('/verify', [ + body('username').notEmpty().withMessage('Username is required'), + body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'), + body('token').isNumeric().withMessage('Token must contain only numbers') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { username, token } = req.body; + + // Get user's TFA secret + const user = await prisma.user.findUnique({ + where: { username }, + select: { + id: true, + tfaEnabled: true, + tfaSecret: true, + tfaBackupCodes: true + } + }); + + if (!user || !user.tfaEnabled || !user.tfaSecret) { + return res.status(400).json({ + error: 'Two-factor authentication is not enabled for this account' + }); + } + + // Check if it's a backup code + const backupCodes = user.tfaBackupCodes ? JSON.parse(user.tfaBackupCodes) : []; + const isBackupCode = backupCodes.includes(token); + + let verified = false; + + if (isBackupCode) { + // Remove the used backup code + const updatedBackupCodes = backupCodes.filter(code => code !== token); + await prisma.user.update({ + where: { id: user.id }, + data: { + tfaBackupCodes: JSON.stringify(updatedBackupCodes) + } + }); + verified = true; + } else { + // Verify TOTP token + verified = speakeasy.totp.verify({ + secret: user.tfaSecret, + encoding: 'base32', + token: token, + window: 2 + }); + } + + if (!verified) { + return res.status(400).json({ + error: 'Invalid verification code' + }); + } + + res.json({ + message: 'Two-factor authentication verified successfully', + userId: user.id + }); + } catch (error) { + console.error('TFA verification error:', error); + res.status(500).json({ error: 'Failed to verify two-factor authentication' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/versionRoutes.js b/backend/src/routes/versionRoutes.js index c90630d..5f92b5c 100644 --- a/backend/src/routes/versionRoutes.js +++ b/backend/src/routes/versionRoutes.js @@ -144,6 +144,12 @@ router.get('/check-updates', authenticateToken, requireManageSettings, async (re try { // Get GitHub repo URL from settings const settings = await prisma.settings.findFirst(); + console.log('Settings retrieved for version check:', { + id: settings?.id, + githubRepoUrl: settings?.githubRepoUrl, + repositoryType: settings?.repositoryType + }); + if (!settings || !settings.githubRepoUrl) { return res.status(400).json({ error: 'GitHub repository URL not configured' }); } @@ -153,6 +159,7 @@ router.get('/check-updates', authenticateToken, requireManageSettings, async (re // git@github.com:owner/repo.git // https://github.com/owner/repo.git const repoUrl = settings.githubRepoUrl; + console.log('Using repository URL:', repoUrl); let owner, repo; if (repoUrl.includes('git@github.com:')) { @@ -167,67 +174,107 @@ router.get('/check-updates', authenticateToken, requireManageSettings, async (re } } + console.log('Extracted owner and repo:', { owner, repo }); + if (!owner || !repo) { return res.status(400).json({ error: 'Invalid GitHub repository URL format' }); } - // Use SSH with deploy keys (secure approach) - const sshRepoUrl = `git@github.com:${owner}/${repo}.git`; + // Determine repository type and set up appropriate access method + const repositoryType = settings.repositoryType || 'public'; + const isPrivate = repositoryType === 'private'; - try { - let sshKeyPath = null; + let latestTag; + + if (isPrivate) { + // Use SSH with deploy keys for private repositories + const sshRepoUrl = `git@github.com:${owner}/${repo}.git`; - // First, try to use the configured SSH key path from settings - if (settings.sshKeyPath) { - try { - require('fs').accessSync(settings.sshKeyPath); - sshKeyPath = settings.sshKeyPath; - console.log(`Using configured SSH key at: ${sshKeyPath}`); - } catch (e) { - console.warn(`Configured SSH key path not accessible: ${settings.sshKeyPath}`); - } - } - - // If no configured path or it's not accessible, try common locations - if (!sshKeyPath) { - const possibleKeyPaths = [ - '/root/.ssh/id_ed25519', // Root user (if service runs as root) - '/root/.ssh/id_rsa', // Root user RSA key - '/home/patchmon/.ssh/id_ed25519', // PatchMon user - '/home/patchmon/.ssh/id_rsa', // PatchMon user RSA key - '/var/www/.ssh/id_ed25519', // Web user - '/var/www/.ssh/id_rsa' // Web user RSA key - ]; + try { + let sshKeyPath = null; - for (const path of possibleKeyPaths) { + // First, try to use the configured SSH key path from settings + if (settings.sshKeyPath) { try { - require('fs').accessSync(path); - sshKeyPath = path; - console.log(`Found SSH key at: ${path}`); - break; + require('fs').accessSync(settings.sshKeyPath); + sshKeyPath = settings.sshKeyPath; + console.log(`Using configured SSH key at: ${sshKeyPath}`); } catch (e) { - // Key not found at this path, try next + console.warn(`Configured SSH key path not accessible: ${settings.sshKeyPath}`); } } - } - - if (!sshKeyPath) { - throw new Error('No SSH deploy key found. Please configure the SSH key path in settings or ensure a deploy key is installed in one of the expected locations.'); - } - - const env = { - ...process.env, - GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` - }; - - // Fetch the latest tag using SSH with deploy key - const { stdout: latestTag } = await execAsync( - `git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`, - { - timeout: 10000, - env: env + + // If no configured path or it's not accessible, try common locations + if (!sshKeyPath) { + const possibleKeyPaths = [ + '/root/.ssh/id_ed25519', // Root user (if service runs as root) + '/root/.ssh/id_rsa', // Root user RSA key + '/home/patchmon/.ssh/id_ed25519', // PatchMon user + '/home/patchmon/.ssh/id_rsa', // PatchMon user RSA key + '/var/www/.ssh/id_ed25519', // Web user + '/var/www/.ssh/id_rsa' // Web user RSA key + ]; + + for (const path of possibleKeyPaths) { + try { + require('fs').accessSync(path); + sshKeyPath = path; + console.log(`Found SSH key at: ${path}`); + break; + } catch (e) { + // Key not found at this path, try next + } + } } - ); + + if (!sshKeyPath) { + throw new Error('No SSH deploy key found. Please configure the SSH key path in settings or ensure a deploy key is installed in one of the expected locations.'); + } + + const env = { + ...process.env, + GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes` + }; + + // Fetch the latest tag using SSH with deploy key + const { stdout: sshLatestTag } = await execAsync( + `git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`, + { + timeout: 10000, + env: env + } + ); + + latestTag = sshLatestTag; + } catch (sshError) { + console.error('SSH Git error:', sshError.message); + throw sshError; + } + } else { + // Use GitHub API for public repositories (no authentication required) + try { + const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; + + const response = await fetch(httpsRepoUrl, { + method: 'GET', + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'PatchMon-Server/1.2.4' + }, + timeout: 10000 + }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); + } + + const releaseData = await response.json(); + latestTag = releaseData.tag_name; + } catch (apiError) { + console.error('GitHub API error:', apiError.message); + throw apiError; + } + } const latestVersion = latestTag.trim().replace('v', ''); // Remove 'v' prefix const currentVersion = '1.2.4'; @@ -239,53 +286,58 @@ router.get('/check-updates', authenticateToken, requireManageSettings, async (re currentVersion, latestVersion, isUpdateAvailable, + repositoryType, latestRelease: { tagName: latestTag.trim(), version: latestVersion, repository: `${owner}/${repo}`, - sshUrl: sshRepoUrl, - sshKeyUsed: sshKeyPath + accessMethod: isPrivate ? 'ssh' : 'api', + ...(isPrivate && { sshUrl: `git@github.com:${owner}/${repo}.git`, sshKeyUsed: settings.sshKeyPath }) } }); - } catch (sshError) { - console.error('SSH Git error:', sshError.message); + } catch (error) { + console.error('Version check error:', error.message); - if (sshError.message.includes('Permission denied') || sshError.message.includes('Host key verification failed')) { + if (error.message.includes('Permission denied') || error.message.includes('Host key verification failed')) { return res.status(403).json({ - error: 'SSH access denied to repository', - suggestion: 'Ensure your deploy key is properly configured and has access to the repository. Check that the key has read access to the repository.' + error: 'Access denied to repository', + suggestion: isPrivate + ? 'Ensure your deploy key is properly configured and has access to the repository. Check that the key has read access to the repository.' + : 'Check that the repository URL is correct and the repository is public.' }); } - if (sshError.message.includes('not found') || sshError.message.includes('does not exist')) { + if (error.message.includes('not found') || error.message.includes('does not exist')) { return res.status(404).json({ error: 'Repository not found', - suggestion: 'Check that the repository URL is correct and accessible with the deploy key.' + suggestion: 'Check that the repository URL is correct and accessible.' }); } - if (sshError.message.includes('No SSH deploy key found')) { + if (error.message.includes('No SSH deploy key found')) { return res.status(400).json({ error: 'No SSH deploy key found', suggestion: 'Please install a deploy key in one of the expected locations: /root/.ssh/, /home/patchmon/.ssh/, or /var/www/.ssh/' }); } + if (error.message.includes('GitHub API error')) { + return res.status(500).json({ + error: 'Failed to fetch repository information via GitHub API', + details: error.message, + suggestion: 'Check that the repository URL is correct and the repository is public.' + }); + } + return res.status(500).json({ error: 'Failed to fetch repository information', - details: sshError.message, - suggestion: 'Check deploy key configuration and repository access permissions.' + details: error.message, + suggestion: isPrivate + ? 'Check deploy key configuration and repository access permissions.' + : 'Check repository URL and ensure it is accessible.' }); } - - } catch (error) { - console.error('Error checking for updates:', error); - res.status(500).json({ - error: 'Failed to check for updates', - details: error.message - }); - } }); // Simple version comparison function diff --git a/backend/src/server.js b/backend/src/server.js index 3d2df33..a6d1216 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -17,6 +17,7 @@ const settingsRoutes = require('./routes/settingsRoutes'); const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes'); const repositoryRoutes = require('./routes/repositoryRoutes'); const versionRoutes = require('./routes/versionRoutes'); +const tfaRoutes = require('./routes/tfaRoutes'); // Initialize Prisma client const prisma = new PrismaClient(); @@ -136,6 +137,7 @@ app.use(`/api/${apiVersion}/settings`, settingsRoutes); app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes); app.use(`/api/${apiVersion}/repositories`, repositoryRoutes); app.use(`/api/${apiVersion}/version`, versionRoutes); +app.use(`/api/${apiVersion}/tfa`, tfaRoutes); // Error handling middleware app.use((err, req, res, next) => { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0ea9f5a..d555290 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( - + + } /> @@ -45,13 +47,6 @@ function App() { } /> - - - - - - } /> @@ -94,6 +89,13 @@ function App() { } /> + + + + + + } /> @@ -108,7 +110,8 @@ function App() { } /> - + + ) diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 86f45c5..f162eeb 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -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" > - + ) : ( <> @@ -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" > - + )} @@ -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} > - +
+ + {sidebarCollapsed && subItem.showUpgradeIcon && ( + + )} +
{!sidebarCollapsed && ( {subItem.name} @@ -375,6 +402,9 @@ const Layout = ({ children }) => { Soon )} + {subItem.showUpgradeIcon && ( + + )} )} @@ -436,17 +466,22 @@ const Layout = ({ children }) => { {/* Updated info */} {stats && ( -
-
- - Updated: {formatRelativeTimeShort(stats.lastUpdated)} +
+
+ + Updated: {formatRelativeTimeShort(stats.lastUpdated)} + {versionInfo && ( + + v{versionInfo.version} + + )}
)} @@ -473,7 +508,7 @@ const Layout = ({ children }) => { {/* Updated info for collapsed sidebar */} {stats && ( -
+
+ {versionInfo && ( + + v{versionInfo.version} + + )}
)}
diff --git a/frontend/src/components/UpgradeNotificationIcon.jsx b/frontend/src/components/UpgradeNotificationIcon.jsx new file mode 100644 index 0000000..f605381 --- /dev/null +++ b/frontend/src/components/UpgradeNotificationIcon.jsx @@ -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 ( + + ) +} + +export default UpgradeNotificationIcon diff --git a/frontend/src/contexts/UpdateNotificationContext.jsx b/frontend/src/contexts/UpdateNotificationContext.jsx new file mode 100644 index 0000000..37070d6 --- /dev/null +++ b/frontend/src/contexts/UpdateNotificationContext.jsx @@ -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 ( + + {children} + + ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index c7ed2dc..6a84aea 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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 = () => {
); + case 'offlineHosts': + return ( +
0 + ? 'bg-warning-50 border-warning-200' + : 'bg-success-50 border-success-200' + }`} + onClick={handleOfflineHostsClick} + > +
+ 0 ? 'text-warning-400' : 'text-success-400' + }`} /> +
+ {stats.cards.offlineHosts > 0 ? ( + <> +

+ {stats.cards.offlineHosts} host{stats.cards.offlineHosts > 1 ? 's' : ''} offline/stale +

+

+ These hosts haven't reported in {formatUpdateIntervalThreshold() * 3}+ minutes. +

+ + ) : ( + <> +

+ All hosts are online +

+

+ No hosts are offline or stale. +

+ + )} +
+
+
+ ); + case 'osDistribution': return (
{
); + case 'osDistributionBar': + return ( +
+

OS Distribution

+
+ +
+
+ ); + case 'updateStatus': return (
{ }, } + 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: [ diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 151dae6..0413bc6 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -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 (
@@ -58,7 +117,8 @@ const Login = () => {

-
+ {!requiresTfa ? ( +
+ ) : ( +
+
+
+ +
+

+ Two-Factor Authentication +

+

+ Enter the 6-digit code from your authenticator app +

+
+ +
+ +
+ +
+
+ + {error && ( +
+
+ +
+

{error}

+
+
+
+ )} + +
+ + + +
+ +
+

+ Don't have access to your authenticator? Use a backup code. +

+
+
+ )}
) diff --git a/frontend/src/pages/Options.jsx b/frontend/src/pages/Options.jsx new file mode 100644 index 0000000..4d28a88 --- /dev/null +++ b/frontend/src/pages/Options.jsx @@ -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 ( +
+
+
+ ) + } + + if (error) { + return ( +
+
+ +
+

+ Error loading host groups +

+

+ {error.message || 'Failed to load host groups'} +

+
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

+ Host Groups +

+

+ Organize your hosts into logical groups for better management +

+
+ +
+ + {/* Host Groups Grid */} + {hostGroups && hostGroups.length > 0 ? ( +
+ {hostGroups.map((group) => ( +
+
+
+
+
+

+ {group.name} +

+ {group.description && ( +

+ {group.description} +

+ )} +
+
+
+ + +
+
+ +
+
+ + {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''} +
+
+
+ ))} +
+ ) : ( +
+ +

+ No host groups yet +

+

+ Create your first host group to organize your hosts +

+ +
+ )} +
+ ) + } + + const renderComingSoonTab = (tabName) => ( +
+ +

+ {tabName} Coming Soon +

+

+ This feature is currently under development and will be available in a future update. +

+
+ ) + + return ( +
+ {/* Page Header */} +
+

+ Options +

+

+ Configure PatchMon parameters and user preferences +

+
+ + {/* Tabs */} +
+ +
+ + {/* Tab Content */} +
+ {activeTab === 'hostgroups' && renderHostGroupsTab()} + {activeTab === 'notifications' && renderComingSoonTab('Notifications')} +
+ + {/* Create Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onSubmit={handleCreate} + isLoading={createMutation.isPending} + /> + )} + + {/* Edit Modal */} + {showEditModal && selectedGroup && ( + { + setShowEditModal(false) + setSelectedGroup(null) + }} + onSubmit={handleUpdate} + isLoading={updateMutation.isPending} + /> + )} + + {/* Delete Confirmation Modal */} + {showDeleteModal && groupToDelete && ( + { + setShowDeleteModal(false) + setGroupToDelete(null) + }} + onConfirm={handleDeleteConfirm} + isLoading={deleteMutation.isPending} + /> + )} +
+ ) +} + +// 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 ( +
+
+

+ Create Host Group +

+ +
+
+ + +
+ +
+ +