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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,40 @@ if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (use sudo)"
fi
# Install required dependencies
info "📦 Installing required dependencies..."
# Detect package manager and install jq
if command -v apt-get >/dev/null 2>&1; then
# Debian/Ubuntu
apt-get update >/dev/null 2>&1
apt-get install -y jq curl >/dev/null 2>&1
elif command -v yum >/dev/null 2>&1; then
# CentOS/RHEL 7
yum install -y jq curl >/dev/null 2>&1
elif command -v dnf >/dev/null 2>&1; then
# CentOS/RHEL 8+/Fedora
dnf install -y jq curl >/dev/null 2>&1
elif command -v zypper >/dev/null 2>&1; then
# openSUSE
zypper install -y jq curl >/dev/null 2>&1
elif command -v pacman >/dev/null 2>&1; then
# Arch Linux
pacman -S --noconfirm jq curl >/dev/null 2>&1
elif command -v apk >/dev/null 2>&1; then
# Alpine Linux
apk add --no-cache jq curl >/dev/null 2>&1
else
warning "Could not detect package manager. Please ensure 'jq' and 'curl' are installed manually."
fi
# Verify jq installation
if ! command -v jq >/dev/null 2>&1; then
error "Failed to install 'jq'. Please install it manually: https://stedolan.github.io/jq/download/"
fi
success "Dependencies installed successfully!"
# Default server URL (will be replaced by backend with configured URL)
PATCHMON_URL="http://localhost:3001"
@@ -137,6 +171,7 @@ fi
success "🎉 PatchMon Agent installation complete!"
echo ""
echo "📋 Installation Summary:"
echo " • Dependencies installed: jq, curl"
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
echo " • Agent version: $AGENT_VERSION"
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "settings" ADD COLUMN "signup_enabled" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -168,6 +168,7 @@ model settings {
last_update_check DateTime?
latest_version String?
update_available Boolean @default(false)
signup_enabled Boolean @default(false)
}
model update_history {

View File

@@ -34,7 +34,7 @@ router.get('/check-admin-users', async (req, res) => {
router.post('/setup-admin', [
body('username').isLength({ min: 1 }).withMessage('Username is required'),
body('email').isEmail().withMessage('Valid email is required'),
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters for security')
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -425,6 +425,17 @@ router.post('/admin/users/:userId/reset-password', authenticateToken, requireMan
}
});
// Check if signup is enabled (public endpoint)
router.get('/signup-enabled', async (req, res) => {
try {
const settings = await prisma.settings.findFirst();
res.json({ signupEnabled: settings?.signup_enabled || false });
} catch (error) {
console.error('Error checking signup status:', error);
res.status(500).json({ error: 'Failed to check signup status' });
}
});
// Public signup endpoint
router.post('/signup', [
body('username').isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
@@ -432,6 +443,12 @@ router.post('/signup', [
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
], async (req, res) => {
try {
// Check if signup is enabled
const settings = await prisma.settings.findFirst();
if (!settings?.signup_enabled) {
return res.status(403).json({ error: 'User signup is currently disabled' });
}
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });

View File

@@ -36,15 +36,12 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
osDistribution,
updateTrends
] = await Promise.all([
// Total hosts count
prisma.hosts.count({
where: { status: 'active' }
}),
// Total hosts count (all hosts regardless of status)
prisma.hosts.count(),
// Hosts needing updates (distinct hosts with packages needing updates)
prisma.hosts.count({
where: {
status: 'active',
host_packages: {
some: {
needs_update: true

View File

@@ -755,7 +755,15 @@ router.get('/admin/list', authenticateToken, requireManageHosts, async (req, res
api_id: true,
agent_version: true,
auto_update: true,
created_at: true
created_at: true,
host_group_id: true,
host_groups: {
select: {
id: true,
name: true,
color: true
}
}
},
orderBy: { created_at: 'desc' }
});
@@ -767,20 +775,124 @@ router.get('/admin/list', authenticateToken, requireManageHosts, async (req, res
}
});
// Admin endpoint to delete multiple hosts
router.delete('/bulk', authenticateToken, requireManageHosts, [
body('hostIds').isArray({ min: 1 }).withMessage('At least one host ID is required'),
body('hostIds.*').isLength({ min: 1 }).withMessage('Each host ID must be provided')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { hostIds } = req.body;
// Verify all hosts exist before deletion
const existingHosts = await prisma.hosts.findMany({
where: { id: { in: hostIds } },
select: { id: true, friendly_name: true }
});
if (existingHosts.length !== hostIds.length) {
const foundIds = existingHosts.map(h => h.id);
const missingIds = hostIds.filter(id => !foundIds.includes(id));
return res.status(404).json({
error: 'Some hosts not found',
missingIds
});
}
// Delete all hosts (cascade will handle related data)
const deleteResult = await prisma.hosts.deleteMany({
where: { id: { in: hostIds } }
});
// Check if all hosts were actually deleted
if (deleteResult.count !== hostIds.length) {
console.warn(`Expected to delete ${hostIds.length} hosts, but only deleted ${deleteResult.count}`);
}
res.json({
message: `${deleteResult.count} host${deleteResult.count !== 1 ? 's' : ''} deleted successfully`,
deletedCount: deleteResult.count,
requestedCount: hostIds.length,
deletedHosts: existingHosts.map(h => ({ id: h.id, friendly_name: h.friendly_name }))
});
} catch (error) {
console.error('Bulk host deletion error:', error);
// Handle specific Prisma errors
if (error.code === 'P2025') {
return res.status(404).json({
error: 'Some hosts were not found or already deleted',
details: 'The hosts may have been deleted by another process or do not exist'
});
}
if (error.code === 'P2003') {
return res.status(400).json({
error: 'Cannot delete hosts due to foreign key constraints',
details: 'Some hosts have related data that prevents deletion'
});
}
res.status(500).json({
error: 'Failed to delete hosts',
details: error.message || 'An unexpected error occurred'
});
}
});
// Admin endpoint to delete host
router.delete('/:hostId', authenticateToken, requireManageHosts, async (req, res) => {
try {
const { hostId } = req.params;
// Check if host exists first
const existingHost = await prisma.hosts.findUnique({
where: { id: hostId },
select: { id: true, friendly_name: true }
});
if (!existingHost) {
return res.status(404).json({
error: 'Host not found',
details: 'The host may have been deleted or does not exist'
});
}
// Delete host and all related data (cascade)
await prisma.hosts.delete({
where: { id: hostId }
});
res.json({ message: 'Host deleted successfully' });
res.json({
message: 'Host deleted successfully',
deletedHost: { id: existingHost.id, friendly_name: existingHost.friendly_name }
});
} catch (error) {
console.error('Host deletion error:', error);
res.status(500).json({ error: 'Failed to delete host' });
// Handle specific Prisma errors
if (error.code === 'P2025') {
return res.status(404).json({
error: 'Host not found',
details: 'The host may have been deleted or does not exist'
});
}
if (error.code === 'P2003') {
return res.status(400).json({
error: 'Cannot delete host due to foreign key constraints',
details: 'The host has related data that prevents deletion'
});
}
res.status(500).json({
error: 'Failed to delete host',
details: error.message || 'An unexpected error occurred'
});
}
});

View File

@@ -112,11 +112,19 @@ router.get('/', async (req, res) => {
return {
...pkg,
affectedHostsCount: pkg._count.hostPackages,
affectedHosts: affectedHosts.map(hp => ({
hostId: hp.host.id,
friendlyName: hp.host.friendly_name,
osType: hp.host.os_type,
currentVersion: hp.current_version,
availableVersion: hp.available_version,
isSecurityUpdate: hp.is_security_update
})),
stats: {
totalInstalls: pkg._count.hostPackages,
updatesNeeded: updatesCount,
securityUpdates: securityCount,
affectedHosts: affectedHosts.map(hp => hp.host)
securityUpdates: securityCount
}
};
})

View File

@@ -47,16 +47,16 @@ router.put('/roles/:role', authenticateToken, requireManageSettings, async (req,
try {
const { role } = req.params;
const {
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
can_view_dashboard,
can_view_hosts,
can_manage_hosts,
can_view_packages,
can_manage_packages,
can_view_users,
can_manage_users,
can_view_reports,
can_export_data,
can_manage_settings
} = req.body;
// Prevent modifying admin role permissions (admin should always have full access)
@@ -67,31 +67,31 @@ router.put('/roles/:role', authenticateToken, requireManageSettings, async (req,
const permissions = await prisma.role_permissions.upsert({
where: { role },
update: {
can_view_dashboard: canViewDashboard,
can_view_hosts: canViewHosts,
can_manage_hosts: canManageHosts,
can_view_packages: canViewPackages,
can_manage_packages: canManagePackages,
can_view_users: canViewUsers,
can_manage_users: canManageUsers,
can_view_reports: canViewReports,
can_export_data: canExportData,
can_manage_settings: canManageSettings,
can_view_dashboard: can_view_dashboard,
can_view_hosts: can_view_hosts,
can_manage_hosts: can_manage_hosts,
can_view_packages: can_view_packages,
can_manage_packages: can_manage_packages,
can_view_users: can_view_users,
can_manage_users: can_manage_users,
can_view_reports: can_view_reports,
can_export_data: can_export_data,
can_manage_settings: can_manage_settings,
updated_at: new Date()
},
create: {
id: require('uuid').v4(),
role,
can_view_dashboard: canViewDashboard,
can_view_hosts: canViewHosts,
can_manage_hosts: canManageHosts,
can_view_packages: canViewPackages,
can_manage_packages: canManagePackages,
can_view_users: canViewUsers,
can_manage_users: canManageUsers,
can_view_reports: canViewReports,
can_export_data: canExportData,
can_manage_settings: canManageSettings,
can_view_dashboard: can_view_dashboard,
can_view_hosts: can_view_hosts,
can_manage_hosts: can_manage_hosts,
can_view_packages: can_view_packages,
can_manage_packages: can_manage_packages,
can_view_users: can_view_users,
can_manage_users: can_manage_users,
can_view_reports: can_view_reports,
can_export_data: can_export_data,
can_manage_settings: can_manage_settings,
updated_at: new Date()
}
});

View File

@@ -40,7 +40,9 @@ async function triggerCrontabUpdates() {
const http = require('http');
const https = require('https');
const settings = await prisma.settings.findFirst();
const settings = await prisma.settings.findFirst({
orderBy: { updated_at: 'desc' }
});
const serverUrl = settings?.server_url || process.env.SERVER_URL || 'http://localhost:3001';
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
const isHttps = url.protocol === 'https:';
@@ -92,7 +94,9 @@ async function triggerCrontabUpdates() {
// Get current settings
router.get('/', authenticateToken, requireManageSettings, async (req, res) => {
try {
let settings = await prisma.settings.findFirst();
let settings = await prisma.settings.findFirst({
orderBy: { updated_at: 'desc' }
});
// If no settings exist, create default settings
if (!settings) {
@@ -106,12 +110,12 @@ router.get('/', authenticateToken, requireManageSettings, async (req, res) => {
frontend_url: 'http://localhost:3000',
update_interval: 60,
auto_update: false,
signup_enabled: false,
updated_at: new Date()
}
});
}
console.log('Returning settings:', settings);
res.json(settings);
} catch (error) {
console.error('Settings fetch error:', error);
@@ -127,6 +131,7 @@ router.put('/', authenticateToken, requireManageSettings, [
body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'),
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('signupEnabled').isBoolean().withMessage('Signup enabled 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) => {
@@ -140,36 +145,23 @@ router.put('/', authenticateToken, requireManageSettings, [
})
], async (req, res) => {
try {
console.log('Settings update request body:', req.body);
const errors = validationResult(req);
if (!errors.isEmpty()) {
console.log('Validation errors:', errors.array());
return res.status(400).json({ errors: errors.array() });
}
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);
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, signupEnabled, githubRepoUrl, repositoryType, sshKeyPath } = req.body;
// Construct server URL from components
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
let settings = await prisma.settings.findFirst();
let settings = await prisma.settings.findFirst({
orderBy: { updated_at: 'desc' }
});
if (settings) {
// Update existing settings
console.log('Updating existing settings with data:', {
serverUrl,
serverProtocol,
serverHost,
serverPort,
frontendUrl,
updateInterval: updateInterval || 60,
autoUpdate: autoUpdate || false,
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.update_interval;
settings = await prisma.settings.update({
@@ -182,13 +174,13 @@ router.put('/', authenticateToken, requireManageSettings, [
frontend_url: frontendUrl,
update_interval: updateInterval || 60,
auto_update: autoUpdate || false,
signup_enabled: signupEnabled || false,
github_repo_url: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
repository_type: repositoryType || 'public',
ssh_key_path: sshKeyPath || null,
updated_at: new Date()
}
});
console.log('Settings updated successfully:', settings);
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
if (oldUpdateInterval !== (updateInterval || 60)) {
@@ -207,6 +199,7 @@ router.put('/', authenticateToken, requireManageSettings, [
frontend_url: frontendUrl,
update_interval: updateInterval || 60,
auto_update: autoUpdate || false,
signup_enabled: signupEnabled || false,
github_repo_url: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
repository_type: repositoryType || 'public',
ssh_key_path: sshKeyPath || null,
@@ -228,7 +221,9 @@ router.put('/', authenticateToken, requireManageSettings, [
// Get server URL for public use (used by installation scripts)
router.get('/server-url', async (req, res) => {
try {
const settings = await prisma.settings.findFirst();
const settings = await prisma.settings.findFirst({
orderBy: { updated_at: 'desc' }
});
if (!settings) {
return res.json({ server_url: 'http://localhost:3001' });
@@ -244,7 +239,9 @@ router.get('/server-url', async (req, res) => {
// Get update interval policy for agents (public endpoint)
router.get('/update-interval', async (req, res) => {
try {
const settings = await prisma.settings.findFirst();
const settings = await prisma.settings.findFirst({
orderBy: { updated_at: 'desc' }
});
if (!settings) {
return res.json({ updateInterval: 60 });
@@ -263,7 +260,9 @@ router.get('/update-interval', async (req, res) => {
// Get auto-update policy for agents (public endpoint)
router.get('/auto-update', async (req, res) => {
try {
const settings = await prisma.settings.findFirst();
const settings = await prisma.settings.findFirst({
orderBy: { updated_at: 'desc' }
});
if (!settings) {
return res.json({ autoUpdate: false });

View File

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

View File

@@ -28,9 +28,11 @@ import {
Settings as SettingsIcon
} from 'lucide-react';
import { dashboardPreferencesAPI } from '../utils/api';
import { useTheme } from '../contexts/ThemeContext';
// Sortable Card Item Component
const SortableCardItem = ({ card, onToggle }) => {
const { isDark } = useTheme();
const {
attributes,
listeners,
@@ -50,7 +52,7 @@ const SortableCardItem = ({ card, onToggle }) => {
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-3 bg-white border border-secondary-200 rounded-lg ${
className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${
isDragging ? 'shadow-lg' : 'shadow-sm'
}`}
>
@@ -58,12 +60,12 @@ const SortableCardItem = ({ card, onToggle }) => {
<button
{...attributes}
{...listeners}
className="text-secondary-400 hover:text-secondary-600 cursor-grab active:cursor-grabbing"
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-secondary-900">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{card.title}
</div>
</div>
@@ -73,8 +75,8 @@ const SortableCardItem = ({ card, onToggle }) => {
onClick={() => onToggle(card.cardId)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
card.enabled
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800'
: 'bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
}`}
>
{card.enabled ? (
@@ -97,6 +99,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const { isDark } = useTheme();
const sensors = useSensors(
useSensor(PointerSensor),
@@ -212,24 +215,24 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<SettingsIcon className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Dashboard Settings
</h3>
</div>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600"
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-secondary-600 mb-6">
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Customize your dashboard by reordering cards and toggling their visibility.
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
</p>
@@ -259,7 +262,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
)}
</div>
<div className="bg-secondary-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending}
@@ -284,7 +287,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
<button
onClick={handleReset}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
@@ -292,7 +295,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
<button
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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