style(frontend): fmt

This commit is contained in:
tigattack
2025-09-24 22:06:42 +01:00
parent 591389a91f
commit b43b20fbe9
34 changed files with 14405 additions and 12628 deletions

View File

@@ -3,4 +3,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
}
};

View File

@@ -1,45 +1,50 @@
import express from 'express';
import path from 'path';
import cors from 'cors';
import { fileURLToPath } from 'url';
import { createProxyMiddleware } from 'http-proxy-middleware';
import cors from "cors";
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend:3001';
const BACKEND_URL = process.env.BACKEND_URL || "http://backend:3001";
// Enable CORS for API calls
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true
}));
app.use(
cors({
origin: process.env.CORS_ORIGIN || "*",
credentials: true,
}),
);
// Proxy API requests to backend
app.use('/api', createProxyMiddleware({
app.use(
"/api",
createProxyMiddleware({
target: BACKEND_URL,
changeOrigin: true,
logLevel: 'info',
logLevel: "info",
onError: (err, req, res) => {
console.error('Proxy error:', err.message);
res.status(500).json({ error: 'Backend service unavailable' });
console.error("Proxy error:", err.message);
res.status(500).json({ error: "Backend service unavailable" });
},
onProxyReq: (proxyReq, req, res) => {
console.log(`Proxying ${req.method} ${req.path} to ${BACKEND_URL}`);
}
}));
},
}),
);
// Serve static files from dist directory
app.use(express.static(path.join(__dirname, 'dist')));
app.use(express.static(path.join(__dirname, "dist")));
// Handle SPA routing - serve index.html for all routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "dist", "index.html"));
});
app.listen(PORT, () => {
console.log(`Frontend server running on port ${PORT}`);
console.log(`Serving from: ${path.join(__dirname, 'dist')}`);
console.log(`Serving from: ${path.join(__dirname, "dist")}`);
});

View File

@@ -1,28 +1,28 @@
import React from 'react'
import { Routes, Route } from 'react-router-dom'
import { AuthProvider, useAuth } 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 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'
import FirstTimeAdminSetup from './components/FirstTimeAdminSetup'
import React from "react";
import { Route, Routes } from "react-router-dom";
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { ThemeProvider } from "./contexts/ThemeContext";
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
import Dashboard from "./pages/Dashboard";
import HostDetail from "./pages/HostDetail";
import Hosts from "./pages/Hosts";
import Login from "./pages/Login";
import Options from "./pages/Options";
import PackageDetail from "./pages/PackageDetail";
import Packages from "./pages/Packages";
import Permissions from "./pages/Permissions";
import Profile from "./pages/Profile";
import Repositories from "./pages/Repositories";
import RepositoryDetail from "./pages/RepositoryDetail";
import Settings from "./pages/Settings";
import Users from "./pages/Users";
function AppRoutes() {
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth()
const isAuth = isAuthenticated() // Call the function to get boolean value
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth();
const isAuth = isAuthenticated(); // Call the function to get boolean value
// Show loading while checking if setup is needed
if (checkingSetup) {
@@ -30,106 +30,144 @@ function AppRoutes() {
<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">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
<p className="text-secondary-600 dark:text-secondary-300">Checking system status...</p>
<p className="text-secondary-600 dark:text-secondary-300">
Checking system status...
</p>
</div>
</div>
)
);
}
// Show first-time setup if no admin users exist
if (needsFirstTimeSetup && !isAuth) {
return <FirstTimeAdminSetup />
return <FirstTimeAdminSetup />;
}
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<Route
path="/"
element={
<ProtectedRoute requirePermission="can_view_dashboard">
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts" element={
}
/>
<Route
path="/hosts"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Hosts />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts/:hostId" element={
}
/>
<Route
path="/hosts/:hostId"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<HostDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/packages" element={
}
/>
<Route
path="/packages"
element={
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<Packages />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories" element={
}
/>
<Route
path="/repositories"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Repositories />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories/:repositoryId" element={
}
/>
<Route
path="/repositories/:repositoryId"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<RepositoryDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/users" element={
}
/>
<Route
path="/users"
element={
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<Users />
</Layout>
</ProtectedRoute>
} />
<Route path="/permissions" element={
}
/>
<Route
path="/permissions"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Permissions />
</Layout>
</ProtectedRoute>
} />
<Route path="/settings" element={
}
/>
<Route
path="/settings"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Settings />
</Layout>
</ProtectedRoute>
} />
<Route path="/options" element={
}
/>
<Route
path="/options"
element={
<ProtectedRoute requirePermission="can_manage_hosts">
<Layout>
<Options />
</Layout>
</ProtectedRoute>
} />
<Route path="/profile" element={
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Layout>
<Profile />
</Layout>
</ProtectedRoute>
} />
<Route path="/packages/:packageId" element={
}
/>
<Route
path="/packages/:packageId"
element={
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<PackageDetail />
</Layout>
</ProtectedRoute>
} />
}
/>
</Routes>
)
);
}
function App() {
@@ -141,7 +179,7 @@ function App() {
</UpdateNotificationProvider>
</AuthProvider>
</ThemeProvider>
)
);
}
export default App
export default App;

View File

@@ -1,34 +1,32 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
DndContext,
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
X,
GripVertical,
Eye,
EyeOff,
Save,
GripVertical,
RotateCcw,
Settings as SettingsIcon
} from 'lucide-react';
import { dashboardPreferencesAPI } from '../utils/api';
import { useTheme } from '../contexts/ThemeContext';
Save,
Settings as SettingsIcon,
X,
} from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTheme } from "../contexts/ThemeContext";
import { dashboardPreferencesAPI } from "../utils/api";
// Sortable Card Item Component
const SortableCardItem = ({ card, onToggle }) => {
@@ -53,7 +51,7 @@ const SortableCardItem = ({ card, onToggle }) => {
ref={setNodeRef}
style={style}
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'
isDragging ? "shadow-lg" : "shadow-sm"
}`}
>
<div className="flex items-center gap-3">
@@ -68,7 +66,9 @@ const SortableCardItem = ({ card, onToggle }) => {
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{card.title}
{card.typeLabel ? (
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">({card.typeLabel})</span>
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">
({card.typeLabel})
</span>
) : null}
</div>
</div>
@@ -78,8 +78,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 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'
? "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 ? (
@@ -108,21 +108,22 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
// Fetch user's dashboard preferences
const { data: preferences, isLoading } = useQuery({
queryKey: ['dashboardPreferences'],
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
enabled: isOpen
queryKey: ["dashboardPreferences"],
queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
enabled: isOpen,
});
// Fetch default card configuration
const { data: defaultCards } = useQuery({
queryKey: ['dashboardDefaultCards'],
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
enabled: isOpen
queryKey: ["dashboardDefaultCards"],
queryFn: () =>
dashboardPreferencesAPI.getDefaults().then((res) => res.data),
enabled: isOpen,
});
// Update preferences mutation
@@ -130,15 +131,18 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
onSuccess: (response) => {
// Optimistically update the query cache with the correct data structure
queryClient.setQueryData(['dashboardPreferences'], response.data.preferences);
queryClient.setQueryData(
["dashboardPreferences"],
response.data.preferences,
);
// Also invalidate to ensure fresh data
queryClient.invalidateQueries(['dashboardPreferences']);
queryClient.invalidateQueries(["dashboardPreferences"]);
setHasChanges(false);
onClose();
},
onError: (error) => {
console.error('Failed to update dashboard preferences:', error);
}
console.error("Failed to update dashboard preferences:", error);
},
});
// Initialize cards when preferences or defaults are loaded
@@ -152,14 +156,26 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
}));
const typeLabelFor = (cardId) => {
if (['totalHosts','hostsNeedingUpdates','totalOutdatedPackages','securityUpdates','upToDateHosts','totalHostGroups','totalUsers','totalRepos'].includes(cardId)) return 'Top card';
if (cardId === 'osDistribution') return 'Pie chart';
if (cardId === 'osDistributionBar') return 'Bar chart';
if (cardId === 'updateStatus') return 'Pie chart';
if (cardId === 'packagePriority') return 'Pie chart';
if (cardId === 'recentUsers') return 'Table';
if (cardId === 'recentCollection') return 'Table';
if (cardId === 'quickStats') return 'Wide card';
if (
[
"totalHosts",
"hostsNeedingUpdates",
"totalOutdatedPackages",
"securityUpdates",
"upToDateHosts",
"totalHostGroups",
"totalUsers",
"totalRepos",
].includes(cardId)
)
return "Top card";
if (cardId === "osDistribution") return "Pie chart";
if (cardId === "osDistributionBar") return "Bar chart";
if (cardId === "updateStatus") return "Pie chart";
if (cardId === "packagePriority") return "Pie chart";
if (cardId === "recentUsers") return "Table";
if (cardId === "recentCollection") return "Table";
if (cardId === "quickStats") return "Wide card";
return undefined;
};
@@ -167,11 +183,13 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
const mergedCards = defaultCards
.map((defaultCard) => {
const userPreference = normalizedPreferences.find(
(p) => p.cardId === defaultCard.cardId
(p) => p.cardId === defaultCard.cardId,
);
return {
...defaultCard,
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
enabled: userPreference
? userPreference.enabled
: defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order,
typeLabel: typeLabelFor(defaultCard.cardId),
};
@@ -187,15 +205,15 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
if (active.id !== over.id) {
setCards((items) => {
const oldIndex = items.findIndex(item => item.cardId === active.id);
const newIndex = items.findIndex(item => item.cardId === over.id);
const oldIndex = items.findIndex((item) => item.cardId === active.id);
const newIndex = items.findIndex((item) => item.cardId === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
// Update order values
return newItems.map((item, index) => ({
...item,
order: index
order: index,
}));
});
setHasChanges(true);
@@ -203,21 +221,19 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
};
const handleToggle = (cardId) => {
setCards(prevCards =>
prevCards.map(card =>
card.cardId === cardId
? { ...card, enabled: !card.enabled }
: card
)
setCards((prevCards) =>
prevCards.map((card) =>
card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
),
);
setHasChanges(true);
};
const handleSave = () => {
const preferences = cards.map(card => ({
const preferences = cards.map((card) => ({
cardId: card.cardId,
enabled: card.enabled,
order: card.order
order: card.order,
}));
updatePreferencesMutation.mutate(preferences);
@@ -225,10 +241,10 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
const handleReset = () => {
if (defaultCards) {
const resetCards = defaultCards.map(card => ({
const resetCards = defaultCards.map((card) => ({
...card,
enabled: true,
order: card.order
order: card.order,
}));
setCards(resetCards);
setHasChanges(true);
@@ -240,7 +256,10 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<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="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity"
onClick={onClose}
/>
<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">
@@ -260,8 +279,9 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
</div>
<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.
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>
{isLoading ? (
@@ -274,7 +294,10 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={cards.map(card => card.cardId)} strategy={verticalListSortingStrategy}>
<SortableContext
items={cards.map((card) => card.cardId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 max-h-96 overflow-y-auto">
{cards.map((card) => (
<SortableCardItem
@@ -295,8 +318,8 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
disabled={!hasChanges || updatePreferencesMutation.isPending}
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
!hasChanges || updatePreferencesMutation.isPending
? 'bg-secondary-400 cursor-not-allowed'
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updatePreferencesMutation.isPending ? (

View File

@@ -1,108 +1,108 @@
import React, { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { UserPlus, Shield, CheckCircle, AlertCircle } from 'lucide-react'
import { AlertCircle, CheckCircle, Shield, UserPlus } from "lucide-react";
import React, { useState } from "react";
import { useAuth } from "../contexts/AuthContext";
const FirstTimeAdminSetup = () => {
const { login } = useAuth()
const { login } = useAuth();
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: ''
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
username: "",
email: "",
password: "",
confirmPassword: "",
firstName: "",
lastName: "",
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const handleInputChange = (e) => {
const { name, value } = e.target
setFormData(prev => ({
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value
}))
[name]: value,
}));
// Clear error when user starts typing
if (error) setError('')
}
if (error) setError("");
};
const validateForm = () => {
if (!formData.firstName.trim()) {
setError('First name is required')
return false
setError("First name is required");
return false;
}
if (!formData.lastName.trim()) {
setError('Last name is required')
return false
setError("Last name is required");
return false;
}
if (!formData.username.trim()) {
setError('Username is required')
return false
setError("Username is required");
return false;
}
if (!formData.email.trim()) {
setError('Email address is required')
return false
setError("Email address is required");
return false;
}
// Enhanced email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
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
setError("Please enter a valid email address (e.g., user@example.com)");
return false;
}
if (formData.password.length < 8) {
setError('Password must be at least 8 characters for security')
return false
setError("Password must be at least 8 characters for security");
return false;
}
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match')
return false
}
return true
setError("Passwords do not match");
return false;
}
return true;
};
const handleSubmit = async (e) => {
e.preventDefault()
e.preventDefault();
if (!validateForm()) return
if (!validateForm()) return;
setIsLoading(true)
setError('')
setIsLoading(true);
setError("");
try {
const response = await fetch('/api/v1/auth/setup-admin', {
method: 'POST',
const response = await fetch("/api/v1/auth/setup-admin", {
method: "POST",
headers: {
'Content-Type': 'application/json'
"Content-Type": "application/json",
},
body: JSON.stringify({
username: formData.username.trim(),
email: formData.email.trim(),
password: formData.password,
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim()
})
})
lastName: formData.lastName.trim(),
}),
});
const data = await response.json()
const data = await response.json();
if (response.ok) {
setSuccess(true)
setSuccess(true);
// Auto-login the user after successful setup
setTimeout(() => {
login(formData.username.trim(), formData.password)
}, 2000)
login(formData.username.trim(), formData.password);
}, 2000);
} else {
setError(data.error || 'Failed to create admin user')
setError(data.error || "Failed to create admin user");
}
} catch (error) {
console.error('Setup error:', error)
setError('Network error. Please try again.')
console.error("Setup error:", error);
setError("Network error. Please try again.");
} finally {
setIsLoading(false)
}
setIsLoading(false);
}
};
if (success) {
return (
@@ -118,7 +118,8 @@ const FirstTimeAdminSetup = () => {
Admin Account Created!
</h1>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Your admin account has been successfully created. You will be automatically logged in shortly.
Your admin account has been successfully created. You will be
automatically logged in shortly.
</p>
<div className="flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
@@ -126,7 +127,7 @@ const FirstTimeAdminSetup = () => {
</div>
</div>
</div>
)
);
}
return (
@@ -151,7 +152,9 @@ const FirstTimeAdminSetup = () => {
<div className="mb-6 p-4 bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-lg">
<div className="flex items-center">
<AlertCircle className="h-5 w-5 text-danger-600 dark:text-danger-400 mr-2" />
<span className="text-danger-700 dark:text-danger-300 text-sm">{error}</span>
<span className="text-danger-700 dark:text-danger-300 text-sm">
{error}
</span>
</div>
</div>
)}
@@ -159,7 +162,10 @@ const FirstTimeAdminSetup = () => {
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<label
htmlFor="firstName"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
First Name
</label>
<input
@@ -175,7 +181,10 @@ const FirstTimeAdminSetup = () => {
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<label
htmlFor="lastName"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Last Name
</label>
<input
@@ -193,7 +202,10 @@ const FirstTimeAdminSetup = () => {
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<label
htmlFor="username"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Username
</label>
<input
@@ -210,7 +222,10 @@ const FirstTimeAdminSetup = () => {
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<label
htmlFor="email"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Email Address
</label>
<input
@@ -227,7 +242,10 @@ const FirstTimeAdminSetup = () => {
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<label
htmlFor="password"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Password
</label>
<input
@@ -244,7 +262,10 @@ const FirstTimeAdminSetup = () => {
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Confirm Password
</label>
<input
@@ -284,14 +305,17 @@ const FirstTimeAdminSetup = () => {
<Shield className="h-5 w-5 text-blue-600 dark:text-blue-400 mr-2 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium mb-1">Admin Privileges</p>
<p>This account will have full administrative access to manage users, hosts, packages, and system settings.</p>
<p>
This account will have full administrative access to manage
users, hosts, packages, and system settings.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
);
};
export default FirstTimeAdminSetup
export default FirstTimeAdminSetup;

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect } from 'react';
import { Edit2, Check, X } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Check, Edit2, X } from "lucide-react";
import React, { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
const InlineEdit = ({
value,
@@ -11,12 +11,12 @@ const InlineEdit = ({
className = "",
disabled = false,
validate = null,
linkTo = null
linkTo = null,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState("");
const inputRef = useRef(null);
useEffect(() => {
@@ -34,13 +34,13 @@ const InlineEdit = ({
if (disabled) return;
setIsEditing(true);
setEditValue(value);
setError('');
setError("");
};
const handleCancel = () => {
setIsEditing(false);
setEditValue(value);
setError('');
setError("");
if (onCancel) onCancel();
};
@@ -63,23 +63,23 @@ const InlineEdit = ({
}
setIsLoading(true);
setError('');
setError("");
try {
await onSave(editValue.trim());
setIsEditing(false);
} catch (err) {
setError(err.message || 'Failed to save');
setError(err.message || "Failed to save");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
@@ -98,12 +98,12 @@ const InlineEdit = ({
maxLength={maxLength}
disabled={isLoading}
className={`flex-1 px-2 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
error ? 'border-red-500' : ''
} ${isLoading ? 'opacity-50' : ''}`}
error ? "border-red-500" : ""
} ${isLoading ? "opacity-50" : ""}`}
/>
<button
onClick={handleSave}
disabled={isLoading || editValue.trim() === ''}
disabled={isLoading || editValue.trim() === ""}
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Save"
>
@@ -118,7 +118,9 @@ const InlineEdit = ({
<X className="h-4 w-4" />
</button>
{error && (
<span className="text-xs text-red-600 dark:text-red-400">{error}</span>
<span className="text-xs text-red-600 dark:text-red-400">
{error}
</span>
)}
</div>
);

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Edit2, Check, X, ChevronDown } from 'lucide-react';
import { Check, ChevronDown, Edit2, X } from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
const InlineGroupEdit = ({
value,
@@ -8,14 +8,18 @@ const InlineGroupEdit = ({
options = [],
className = "",
disabled = false,
placeholder = "Select group..."
placeholder = "Select group...",
}) => {
const [isEditing, setIsEditing] = useState(false);
const [selectedValue, setSelectedValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const dropdownRef = useRef(null);
const buttonRef = useRef(null);
@@ -40,7 +44,7 @@ const InlineGroupEdit = ({
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: rect.width
width: rect.width,
});
}
};
@@ -55,13 +59,13 @@ const InlineGroupEdit = ({
if (isOpen) {
calculateDropdownPosition();
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('resize', calculateDropdownPosition);
window.addEventListener('scroll', calculateDropdownPosition);
document.addEventListener("mousedown", handleClickOutside);
window.addEventListener("resize", calculateDropdownPosition);
window.addEventListener("scroll", calculateDropdownPosition);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', calculateDropdownPosition);
window.removeEventListener('scroll', calculateDropdownPosition);
document.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener("resize", calculateDropdownPosition);
window.removeEventListener("scroll", calculateDropdownPosition);
};
}
}, [isOpen]);
@@ -70,7 +74,7 @@ const InlineGroupEdit = ({
if (disabled) return;
setIsEditing(true);
setSelectedValue(value);
setError('');
setError("");
// Automatically open dropdown when editing starts
setTimeout(() => {
setIsOpen(true);
@@ -80,7 +84,7 @@ const InlineGroupEdit = ({
const handleCancel = () => {
setIsEditing(false);
setSelectedValue(value);
setError('');
setError("");
setIsOpen(false);
if (onCancel) onCancel();
};
@@ -96,7 +100,7 @@ const InlineGroupEdit = ({
}
setIsLoading(true);
setError('');
setError("");
try {
await onSave(selectedValue);
@@ -105,17 +109,17 @@ const InlineGroupEdit = ({
setIsEditing(false);
setIsOpen(false);
} catch (err) {
setError(err.message || 'Failed to save');
setError(err.message || "Failed to save");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
@@ -123,20 +127,20 @@ const InlineGroupEdit = ({
const displayValue = useMemo(() => {
if (!value) {
return 'Ungrouped';
return "Ungrouped";
}
const option = options.find(opt => opt.id === value);
return option ? option.name : 'Unknown Group';
const option = options.find((opt) => opt.id === value);
return option ? option.name : "Unknown Group";
}, [value, options]);
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';
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);
return options.find((opt) => opt.id === value);
}, [value, options]);
if (isEditing) {
@@ -151,11 +155,14 @@ const InlineGroupEdit = ({
onKeyDown={handleKeyDown}
disabled={isLoading}
className={`w-full px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent flex items-center justify-between ${
error ? 'border-red-500' : ''
} ${isLoading ? 'opacity-50' : ''}`}
error ? "border-red-500" : ""
} ${isLoading ? "opacity-50" : ""}`}
>
<span className="truncate">
{selectedValue ? options.find(opt => opt.id === selectedValue)?.name || 'Unknown Group' : 'Ungrouped'}
{selectedValue
? options.find((opt) => opt.id === selectedValue)?.name ||
"Unknown Group"
: "Ungrouped"}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</button>
@@ -167,7 +174,7 @@ const InlineGroupEdit = ({
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
minWidth: '200px'
minWidth: "200px",
}}
>
<div className="py-1">
@@ -178,7 +185,9 @@ const InlineGroupEdit = ({
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === null ? 'bg-primary-50 dark:bg-primary-900/20' : ''
selectedValue === null
? "bg-primary-50 dark:bg-primary-900/20"
: ""
}`}
>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
@@ -194,7 +203,9 @@ const InlineGroupEdit = ({
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === option.id ? 'bg-primary-50 dark:bg-primary-900/20' : ''
selectedValue === option.id
? "bg-primary-50 dark:bg-primary-900/20"
: ""
}`}
>
<span
@@ -227,7 +238,9 @@ const InlineGroupEdit = ({
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">{error}</span>
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">
{error}
</span>
)}
</div>
);

View File

@@ -1,268 +1,317 @@
import React from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useQuery } from "@tanstack/react-query";
import {
Home,
Server,
Package,
Shield,
Activity,
BarChart3,
Menu,
X,
LogOut,
User,
Users,
Settings,
UserCircle,
ChevronLeft,
ChevronRight,
Clock,
RefreshCw,
GitBranch,
Wrench,
Container,
Plus,
Activity,
Cog,
Container,
FileText,
GitBranch,
Github,
MessageCircle,
Globe,
Home,
LogOut,
Mail,
Menu,
MessageCircle,
Package,
Plus,
RefreshCw,
Server,
Settings,
Shield,
Star,
Globe
} from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../contexts/AuthContext'
import { useUpdateNotification } from '../contexts/UpdateNotificationContext'
import { dashboardAPI, formatRelativeTime, versionAPI } from '../utils/api'
import UpgradeNotificationIcon from './UpgradeNotificationIcon'
User,
UserCircle,
Users,
Wrench,
X,
} from "lucide-react";
import React, { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
import { dashboardAPI, formatRelativeTime, versionAPI } from "../utils/api";
import UpgradeNotificationIcon from "./UpgradeNotificationIcon";
const Layout = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
// Load sidebar state from localStorage, default to false
const saved = localStorage.getItem('sidebarCollapsed')
return saved ? JSON.parse(saved) : false
})
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [githubStars, setGithubStars] = useState(null)
const location = useLocation()
const { user, logout, canViewDashboard, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canViewReports, canExportData, canManageSettings } = useAuth()
const { updateAvailable } = useUpdateNotification()
const userMenuRef = useRef(null)
const saved = localStorage.getItem("sidebarCollapsed");
return saved ? JSON.parse(saved) : false;
});
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [githubStars, setGithubStars] = useState(null);
const location = useLocation();
const {
user,
logout,
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings,
} = useAuth();
const { updateAvailable } = useUpdateNotification();
const userMenuRef = useRef(null);
// Fetch dashboard stats for the "Last updated" info
const { data: stats, refetch, isFetching } = useQuery({
queryKey: ['dashboardStats'],
queryFn: () => dashboardAPI.getStats().then(res => res.data),
const {
data: stats,
refetch,
isFetching,
} = useQuery({
queryKey: ["dashboardStats"],
queryFn: () => dashboardAPI.getStats().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
});
// Fetch version info
const { data: versionInfo } = useQuery({
queryKey: ['versionInfo'],
queryFn: () => versionAPI.getCurrent().then(res => res.data),
queryKey: ["versionInfo"],
queryFn: () => versionAPI.getCurrent().then((res) => res.data),
staleTime: 300000, // Consider data stale after 5 minutes
})
});
// Build navigation based on permissions
const buildNavigation = () => {
const nav = []
const nav = [];
// Dashboard - only show if user can view dashboard
if (canViewDashboard()) {
nav.push({ name: 'Dashboard', href: '/', icon: Home })
nav.push({ name: "Dashboard", href: "/", icon: Home });
}
// Inventory section - only show if user has any inventory permissions
if (canViewHosts() || canViewPackages() || canViewReports()) {
const inventoryItems = []
const inventoryItems = [];
if (canViewHosts()) {
inventoryItems.push({ name: 'Hosts', href: '/hosts', icon: Server })
inventoryItems.push({ name: 'Repos', href: '/repositories', icon: GitBranch })
inventoryItems.push({ name: "Hosts", href: "/hosts", icon: Server });
inventoryItems.push({
name: "Repos",
href: "/repositories",
icon: GitBranch,
});
}
if (canViewPackages()) {
inventoryItems.push({ name: 'Packages', href: '/packages', icon: Package })
inventoryItems.push({
name: "Packages",
href: "/packages",
icon: Package,
});
}
if (canViewReports()) {
inventoryItems.push(
{ name: 'Services', href: '/services', icon: Activity, comingSoon: true },
{ name: 'Docker', href: '/docker', icon: Container, comingSoon: true },
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true }
)
{
name: "Services",
href: "/services",
icon: Activity,
comingSoon: true,
},
{
name: "Docker",
href: "/docker",
icon: Container,
comingSoon: true,
},
{
name: "Reporting",
href: "/reporting",
icon: BarChart3,
comingSoon: true,
},
);
}
if (inventoryItems.length > 0) {
nav.push({
section: 'Inventory',
items: inventoryItems
})
section: "Inventory",
items: inventoryItems,
});
}
}
// PatchMon Users section - only show if user can view/manage users
if (canViewUsers() || canManageUsers()) {
const userItems = []
const userItems = [];
if (canViewUsers()) {
userItems.push({ name: 'Users', href: '/users', icon: Users })
userItems.push({ name: "Users", href: "/users", icon: Users });
}
if (canManageSettings()) {
userItems.push({ name: 'Permissions', href: '/permissions', icon: Shield })
userItems.push({
name: "Permissions",
href: "/permissions",
icon: Shield,
});
}
if (userItems.length > 0) {
nav.push({
section: 'PatchMon Users',
items: userItems
})
section: "PatchMon Users",
items: userItems,
});
}
}
// Settings section - only show if user has any settings permissions
if (canManageSettings() || canViewReports() || canExportData()) {
const settingsItems = []
const settingsItems = [];
if (canManageSettings()) {
settingsItems.push({
name: 'PatchMon Options',
href: '/options',
icon: Settings
})
name: "PatchMon Options",
href: "/options",
icon: Settings,
});
settingsItems.push({
name: 'Server Config',
href: '/settings',
name: "Server Config",
href: "/settings",
icon: Wrench,
showUpgradeIcon: updateAvailable
})
showUpgradeIcon: updateAvailable,
});
}
if (canViewReports() || canExportData()) {
settingsItems.push({
name: 'Audit Log',
href: '/audit-log',
name: "Audit Log",
href: "/audit-log",
icon: FileText,
comingSoon: true
})
comingSoon: true,
});
}
if (settingsItems.length > 0) {
nav.push({
section: 'Settings',
items: settingsItems
})
section: "Settings",
items: settingsItems,
});
}
}
return nav
}
return nav;
};
const navigation = buildNavigation()
const navigation = buildNavigation();
const isActive = (path) => location.pathname === path
const isActive = (path) => location.pathname === path;
// Get page title based on current route
const getPageTitle = () => {
const path = location.pathname
const path = location.pathname;
if (path === '/') return 'Dashboard'
if (path === '/hosts') return 'Hosts'
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 'PatchMon Options'
if (path === '/audit-log') return 'Audit Log'
if (path === '/profile') return 'My Profile'
if (path.startsWith('/hosts/')) return 'Host Details'
if (path.startsWith('/packages/')) return 'Package Details'
if (path === "/") return "Dashboard";
if (path === "/hosts") return "Hosts";
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 "PatchMon Options";
if (path === "/audit-log") return "Audit Log";
if (path === "/profile") return "My Profile";
if (path.startsWith("/hosts/")) return "Host Details";
if (path.startsWith("/packages/")) return "Package Details";
return 'PatchMon'
}
return "PatchMon";
};
const handleLogout = async () => {
await logout()
setUserMenuOpen(false)
}
await logout();
setUserMenuOpen(false);
};
const handleAddHost = () => {
// Navigate to hosts page with add modal parameter
window.location.href = '/hosts?action=add'
}
window.location.href = "/hosts?action=add";
};
// Fetch GitHub stars count
const fetchGitHubStars = async () => {
try {
const response = await fetch('https://api.github.com/repos/9technologygroup/patchmon.net')
const response = await fetch(
"https://api.github.com/repos/9technologygroup/patchmon.net",
);
if (response.ok) {
const data = await response.json()
setGithubStars(data.stargazers_count)
const data = await response.json();
setGithubStars(data.stargazers_count);
}
} catch (error) {
console.error('Failed to fetch GitHub stars:', error)
}
console.error("Failed to fetch GitHub stars:", error);
}
};
// Short format for navigation area
const formatRelativeTimeShort = (date) => {
if (!date) return 'Never'
if (!date) return "Never";
const now = new Date()
const dateObj = new Date(date)
const now = new Date();
const dateObj = new Date(date);
// Check if date is valid
if (isNaN(dateObj.getTime())) return 'Invalid date'
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)
const days = Math.floor(hours / 24)
const diff = now - dateObj;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return `${seconds}s ago`
}
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return `${seconds}s ago`;
};
// Save sidebar collapsed state to localStorage
useEffect(() => {
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed))
}, [sidebarCollapsed])
localStorage.setItem("sidebarCollapsed", JSON.stringify(sidebarCollapsed));
}, [sidebarCollapsed]);
// Close user menu when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (userMenuRef.current && !userMenuRef.current.contains(event.target)) {
setUserMenuOpen(false)
}
setUserMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
// Fetch GitHub stars on component mount
useEffect(() => {
fetchGitHubStars()
}, [])
fetchGitHubStars();
}, []);
return (
<div className="min-h-screen bg-secondary-50">
{/* Mobile sidebar */}
<div className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? 'block' : 'hidden'}`}>
<div className="fixed inset-0 bg-secondary-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
<div
className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
>
<div
className="fixed inset-0 bg-secondary-600 bg-opacity-75"
onClick={() => setSidebarOpen(false)}
/>
<div className="relative flex w-full max-w-xs flex-col bg-white pb-4 pt-5 shadow-xl">
<div className="absolute right-0 top-0 -mr-12 pt-2">
<button
@@ -276,7 +325,9 @@ const Layout = ({ children }) => {
<div className="flex flex-shrink-0 items-center px-4">
<div className="flex items-center">
<Shield className="h-8 w-8 text-primary-600" />
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">PatchMon</h1>
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
PatchMon
</h1>
</div>
</div>
<nav className="mt-8 flex-1 space-y-6 px-2">
@@ -285,7 +336,9 @@ const Layout = ({ children }) => {
<div className="px-2 py-4 text-center">
<div className="text-sm text-secondary-500 dark:text-secondary-400">
<p className="mb-2">Limited access</p>
<p className="text-xs">Contact your administrator for additional permissions</p>
<p className="text-xs">
Contact your administrator for additional permissions
</p>
</div>
</div>
)}
@@ -298,15 +351,15 @@ const Layout = ({ children }) => {
to={item.href}
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
isActive(item.href)
? 'bg-primary-100 text-primary-900'
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
? "bg-primary-100 text-primary-900"
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
}`}
onClick={() => setSidebarOpen(false)}
>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</Link>
)
);
} else if (item.section) {
// Section with items
return (
@@ -317,14 +370,14 @@ const Layout = ({ children }) => {
<div className="space-y-1">
{item.items.map((subItem) => (
<div key={subItem.name}>
{subItem.name === 'Hosts' && canManageHosts() ? (
{subItem.name === "Hosts" && canManageHosts() ? (
// Special handling for Hosts item with integrated + button (mobile)
<Link
to={subItem.href}
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
isActive(subItem.href)
? 'bg-primary-100 text-primary-900'
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
? "bg-primary-100 text-primary-900"
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
}`}
onClick={() => setSidebarOpen(false)}
>
@@ -334,9 +387,9 @@ const Layout = ({ children }) => {
</span>
<button
onClick={(e) => {
e.preventDefault()
setSidebarOpen(false)
handleAddHost()
e.preventDefault();
setSidebarOpen(false);
handleAddHost();
}}
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
title="Add Host"
@@ -350,10 +403,14 @@ const Layout = ({ children }) => {
to={subItem.href}
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
isActive(subItem.href)
? 'bg-primary-100 text-primary-900'
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
} ${subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={subItem.comingSoon ? (e) => e.preventDefault() : () => setSidebarOpen(false)}
? "bg-primary-100 text-primary-900"
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
} ${subItem.comingSoon ? "opacity-50 cursor-not-allowed" : ""}`}
onClick={
subItem.comingSoon
? (e) => e.preventDefault()
: () => setSidebarOpen(false)
}
>
<subItem.icon className="mr-3 h-5 w-5" />
<span className="flex items-center gap-2">
@@ -370,24 +427,30 @@ const Layout = ({ children }) => {
))}
</div>
</div>
)
);
}
return null
return null;
})}
</nav>
</div>
</div>
{/* Desktop sidebar */}
<div className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
sidebarCollapsed ? 'lg:w-16' : 'lg:w-64'
} bg-white dark:bg-secondary-800`}>
<div className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
sidebarCollapsed ? 'px-2 shadow-lg' : 'px-6'
}`}>
<div className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
sidebarCollapsed ? 'justify-center' : 'justify-between'
}`}>
<div
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
sidebarCollapsed ? "lg:w-16" : "lg:w-64"
} bg-white dark:bg-secondary-800`}
>
<div
className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
sidebarCollapsed ? "px-2 shadow-lg" : "px-6"
}`}
>
<div
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
sidebarCollapsed ? "justify-center" : "justify-between"
}`}
>
{sidebarCollapsed ? (
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
@@ -400,7 +463,9 @@ const Layout = ({ children }) => {
<>
<div className="flex items-center">
<Shield className="h-8 w-8 text-primary-600" />
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">PatchMon</h1>
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
PatchMon
</h1>
</div>
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
@@ -419,7 +484,9 @@ const Layout = ({ children }) => {
<li className="px-2 py-4 text-center">
<div className="text-sm text-secondary-500 dark:text-secondary-400">
<p className="mb-2">Limited access</p>
<p className="text-xs">Contact your administrator for additional permissions</p>
<p className="text-xs">
Contact your administrator for additional permissions
</p>
</div>
</li>
)}
@@ -432,18 +499,20 @@ const Layout = ({ children }) => {
to={item.href}
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
isActive(item.href)
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'}`}
title={sidebarCollapsed ? item.name : ''}
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
} ${sidebarCollapsed ? "justify-center p-2" : "p-2"}`}
title={sidebarCollapsed ? item.name : ""}
>
<item.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
<item.icon
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
/>
{!sidebarCollapsed && (
<span className="truncate">{item.name}</span>
)}
</Link>
</li>
)
);
} else if (item.section) {
// Section with items
return (
@@ -453,22 +522,26 @@ const Layout = ({ children }) => {
{item.section}
</h3>
)}
<ul className={`space-y-1 ${sidebarCollapsed ? '' : '-mx-2'}`}>
<ul
className={`space-y-1 ${sidebarCollapsed ? "" : "-mx-2"}`}
>
{item.items.map((subItem) => (
<li key={subItem.name}>
{subItem.name === 'Hosts' && canManageHosts() ? (
{subItem.name === "Hosts" && canManageHosts() ? (
// Special handling for Hosts item with integrated + button
<div className="flex items-center gap-1">
<Link
to={subItem.href}
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 flex-1 ${
isActive(subItem.href)
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'}`}
title={sidebarCollapsed ? subItem.name : ''}
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
} ${sidebarCollapsed ? "justify-center p-2" : "p-2"}`}
title={sidebarCollapsed ? subItem.name : ""}
>
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
<subItem.icon
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
/>
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2 flex-1">
{subItem.name}
@@ -477,8 +550,8 @@ const Layout = ({ children }) => {
{!sidebarCollapsed && (
<button
onClick={(e) => {
e.preventDefault()
handleAddHost()
e.preventDefault();
handleAddHost();
}}
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
title="Add Host"
@@ -494,17 +567,28 @@ const Layout = ({ children }) => {
to={subItem.href}
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 ${
isActive(subItem.href)
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
} ${sidebarCollapsed ? 'justify-center p-2 relative' : 'p-2'} ${
subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''
? "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 relative" : "p-2"} ${
subItem.comingSoon
? "opacity-50 cursor-not-allowed"
: ""
}`}
title={sidebarCollapsed ? subItem.name : ''}
onClick={subItem.comingSoon ? (e) => e.preventDefault() : undefined}
title={sidebarCollapsed ? subItem.name : ""}
onClick={
subItem.comingSoon
? (e) => e.preventDefault()
: undefined
}
>
<div className={`flex items-center ${sidebarCollapsed ? 'justify-center' : ''}`}>
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
{sidebarCollapsed && subItem.showUpgradeIcon && (
<div
className={`flex items-center ${sidebarCollapsed ? "justify-center" : ""}`}
>
<subItem.icon
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
/>
{sidebarCollapsed &&
subItem.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3 absolute -top-1 -right-1" />
)}
</div>
@@ -527,14 +611,13 @@ const Layout = ({ children }) => {
))}
</ul>
</li>
)
);
}
return null
return null;
})}
</ul>
</nav>
{/* Profile Section - Bottom of Sidebar */}
<div className="border-t border-secondary-200 dark:border-secondary-600">
{!sidebarCollapsed ? (
@@ -544,26 +627,30 @@ const Layout = ({ children }) => {
<Link
to="/profile"
className={`flex-1 min-w-0 rounded-md p-2 transition-all duration-200 ${
isActive('/profile')
? 'bg-primary-50 dark:bg-primary-600'
: 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
isActive("/profile")
? "bg-primary-50 dark:bg-primary-600"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
<div className="flex items-center gap-x-3">
<UserCircle className={`h-5 w-5 shrink-0 ${
isActive('/profile')
? 'text-primary-700 dark:text-white'
: 'text-secondary-500 dark:text-secondary-400'
}`} />
<UserCircle
className={`h-5 w-5 shrink-0 ${
isActive("/profile")
? "text-primary-700 dark:text-white"
: "text-secondary-500 dark:text-secondary-400"
}`}
/>
<div className="flex items-center gap-x-2">
<span className={`text-sm leading-6 font-semibold truncate ${
isActive('/profile')
? 'text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200'
}`}>
<span
className={`text-sm leading-6 font-semibold truncate ${
isActive("/profile")
? "text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200"
}`}
>
{user?.first_name || user?.username}
</span>
{user?.role === 'admin' && (
{user?.role === "admin" && (
<span className="inline-flex items-center rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-800">
Admin
</span>
@@ -584,14 +671,18 @@ const Layout = ({ children }) => {
<div className="px-2 py-1 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-x-1 text-xs text-secondary-500 dark:text-secondary-400">
<Clock className="h-3 w-3 flex-shrink-0" />
<span className="truncate">Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
<span className="truncate">
Updated: {formatRelativeTimeShort(stats.lastUpdated)}
</span>
<button
onClick={() => refetch()}
disabled={isFetching}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0 disabled:opacity-50"
title="Refresh data"
>
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`}
/>
</button>
{versionInfo && (
<span className="text-xs text-secondary-400 dark:text-secondary-500 flex-shrink-0">
@@ -607,9 +698,9 @@ const Layout = ({ children }) => {
<Link
to="/profile"
className={`flex items-center justify-center p-2 rounded-md transition-colors ${
isActive('/profile')
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700'
isActive("/profile")
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
title={`My Profile (${user?.username})`}
>
@@ -631,7 +722,9 @@ const Layout = ({ children }) => {
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded disabled:opacity-50"
title={`Refresh data - Updated: ${formatRelativeTimeShort(stats.lastUpdated)}`}
>
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`}
/>
</button>
{versionInfo && (
<span className="text-xs text-secondary-400 dark:text-secondary-500 mt-1">
@@ -647,9 +740,11 @@ const Layout = ({ children }) => {
</div>
{/* Main content */}
<div className={`flex flex-col min-h-screen transition-all duration-300 ${
sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-64'
}`}>
<div
className={`flex flex-col min-h-screen transition-all duration-300 ${
sidebarCollapsed ? "lg:pl-16" : "lg:pl-64"
}`}
>
{/* Top bar */}
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
<button
@@ -717,13 +812,11 @@ const Layout = ({ children }) => {
</div>
<main className="flex-1 py-6 bg-secondary-50 dark:bg-secondary-800">
<div className="px-4 sm:px-6 lg:px-8">
{children}
</div>
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
</main>
</div>
</div>
)
}
);
};
export default Layout
export default Layout;

View File

@@ -1,20 +1,24 @@
import React from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import React from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => {
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth()
const ProtectedRoute = ({
children,
requireAdmin = false,
requirePermission = null,
}) => {
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
);
}
if (!isAuthenticated()) {
return <Navigate to="/login" replace />
return <Navigate to="/login" replace />;
}
// Check admin requirement
@@ -22,11 +26,15 @@ const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = nu
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
<p className="text-secondary-600">You don't have permission to access this page.</p>
<h2 className="text-xl font-semibold text-secondary-900 mb-2">
Access Denied
</h2>
<p className="text-secondary-600">
You don't have permission to access this page.
</p>
</div>
</div>
)
);
}
// Check specific permission requirement
@@ -34,14 +42,18 @@ const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = nu
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
<p className="text-secondary-600">You don't have permission to access this page.</p>
<h2 className="text-xl font-semibold text-secondary-900 mb-2">
Access Denied
</h2>
<p className="text-secondary-600">
You don't have permission to access this page.
</p>
</div>
</div>
)
);
}
return children
}
return children;
};
export default ProtectedRoute
export default ProtectedRoute;

View File

@@ -1,15 +1,15 @@
import React from 'react'
import { ArrowUpCircle } from 'lucide-react'
import { ArrowUpCircle } from "lucide-react";
import React from "react";
const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
if (!show) return null
if (!show) return null;
return (
<ArrowUpCircle
className={`${className} text-red-500 animate-pulse`}
title="Update available"
/>
)
}
);
};
export default UpgradeNotificationIcon
export default UpgradeNotificationIcon;

View File

@@ -1,266 +1,275 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
const AuthContext = createContext()
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext)
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [token, setToken] = useState(null)
const [permissions, setPermissions] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [permissionsLoading, setPermissionsLoading] = useState(false)
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false)
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [permissions, setPermissions] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [permissionsLoading, setPermissionsLoading] = useState(false);
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false);
const [checkingSetup, setCheckingSetup] = useState(true)
const [checkingSetup, setCheckingSetup] = useState(true);
// Initialize auth state from localStorage
useEffect(() => {
const storedToken = localStorage.getItem('token')
const storedUser = localStorage.getItem('user')
const storedPermissions = localStorage.getItem('permissions')
const storedToken = localStorage.getItem("token");
const storedUser = localStorage.getItem("user");
const storedPermissions = localStorage.getItem("permissions");
if (storedToken && storedUser) {
try {
setToken(storedToken)
setUser(JSON.parse(storedUser))
setToken(storedToken);
setUser(JSON.parse(storedUser));
if (storedPermissions) {
setPermissions(JSON.parse(storedPermissions))
setPermissions(JSON.parse(storedPermissions));
} else {
// Fetch permissions if not stored
fetchPermissions(storedToken)
fetchPermissions(storedToken);
}
} catch (error) {
console.error('Error parsing stored user data:', error)
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('permissions')
console.error("Error parsing stored user data:", error);
localStorage.removeItem("token");
localStorage.removeItem("user");
localStorage.removeItem("permissions");
}
}
setIsLoading(false)
}, [])
setIsLoading(false);
}, []);
// Refresh permissions when user logs in (no automatic refresh)
useEffect(() => {
if (token && user) {
// Only refresh permissions once when user logs in
refreshPermissions()
refreshPermissions();
}
}, [token, user])
}, [token, user]);
const fetchPermissions = async (authToken) => {
try {
setPermissionsLoading(true)
const response = await fetch('/api/v1/permissions/user-permissions', {
setPermissionsLoading(true);
const response = await fetch("/api/v1/permissions/user-permissions", {
headers: {
'Authorization': `Bearer ${authToken}`,
Authorization: `Bearer ${authToken}`,
},
})
});
if (response.ok) {
const data = await response.json()
setPermissions(data)
localStorage.setItem('permissions', JSON.stringify(data))
return data
const data = await response.json();
setPermissions(data);
localStorage.setItem("permissions", JSON.stringify(data));
return data;
} else {
console.error('Failed to fetch permissions')
return null
console.error("Failed to fetch permissions");
return null;
}
} catch (error) {
console.error('Error fetching permissions:', error)
return null
console.error("Error fetching permissions:", error);
return null;
} finally {
setPermissionsLoading(false)
}
setPermissionsLoading(false);
}
};
const refreshPermissions = async () => {
if (token) {
const updatedPermissions = await fetchPermissions(token)
return updatedPermissions
}
return null
const updatedPermissions = await fetchPermissions(token);
return updatedPermissions;
}
return null;
};
const login = async (username, password) => {
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
const response = await fetch("/api/v1/auth/login", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
})
});
const data = await response.json()
const data = await response.json();
if (response.ok) {
setToken(data.token)
setUser(data.user)
localStorage.setItem('token', data.token)
localStorage.setItem('user', JSON.stringify(data.user))
setToken(data.token);
setUser(data.user);
localStorage.setItem("token", data.token);
localStorage.setItem("user", JSON.stringify(data.user));
// Fetch user permissions after successful login
const userPermissions = await fetchPermissions(data.token)
const userPermissions = await fetchPermissions(data.token);
if (userPermissions) {
setPermissions(userPermissions)
setPermissions(userPermissions);
}
return { success: true }
return { success: true };
} else {
return { success: false, error: data.error || 'Login failed' }
return { success: false, error: data.error || "Login failed" };
}
} catch (error) {
return { success: false, error: 'Network error occurred' }
}
return { success: false, error: "Network error occurred" };
}
};
const logout = async () => {
try {
if (token) {
await fetch('/api/v1/auth/logout', {
method: 'POST',
await fetch("/api/v1/auth/logout", {
method: "POST",
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
});
}
} catch (error) {
console.error('Logout error:', error)
console.error("Logout error:", error);
} finally {
setToken(null)
setUser(null)
setPermissions(null)
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('permissions')
}
setToken(null);
setUser(null);
setPermissions(null);
localStorage.removeItem("token");
localStorage.removeItem("user");
localStorage.removeItem("permissions");
}
};
const updateProfile = async (profileData) => {
try {
const response = await fetch('/api/v1/auth/profile', {
method: 'PUT',
const response = await fetch("/api/v1/auth/profile", {
method: "PUT",
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(profileData),
})
});
const data = await response.json()
const data = await response.json();
if (response.ok) {
setUser(data.user)
localStorage.setItem('user', JSON.stringify(data.user))
return { success: true, user: data.user }
setUser(data.user);
localStorage.setItem("user", JSON.stringify(data.user));
return { success: true, user: data.user };
} else {
return { success: false, error: data.error || 'Update failed' }
return { success: false, error: data.error || "Update failed" };
}
} catch (error) {
return { success: false, error: 'Network error occurred' }
}
return { success: false, error: "Network error occurred" };
}
};
const changePassword = async (currentPassword, newPassword) => {
try {
const response = await fetch('/api/v1/auth/change-password', {
method: 'PUT',
const response = await fetch("/api/v1/auth/change-password", {
method: "PUT",
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ currentPassword, newPassword }),
})
});
const data = await response.json()
const data = await response.json();
if (response.ok) {
return { success: true }
return { success: true };
} else {
return { success: false, error: data.error || 'Password change failed' }
return {
success: false,
error: data.error || "Password change failed",
};
}
} catch (error) {
return { success: false, error: 'Network error occurred' }
}
return { success: false, error: "Network error occurred" };
}
};
const isAuthenticated = () => {
return !!(token && user)
}
return !!(token && user);
};
const isAdmin = () => {
return user?.role === 'admin'
}
return user?.role === "admin";
};
// Permission checking functions
const hasPermission = (permission) => {
// If permissions are still loading, return false to show loading state
if (permissionsLoading) {
return false
}
return permissions?.[permission] === true
return false;
}
return permissions?.[permission] === true;
};
const canViewDashboard = () => hasPermission('can_view_dashboard')
const canViewHosts = () => hasPermission('can_view_hosts')
const canManageHosts = () => hasPermission('can_manage_hosts')
const canViewPackages = () => hasPermission('can_view_packages')
const canManagePackages = () => hasPermission('can_manage_packages')
const canViewUsers = () => hasPermission('can_view_users')
const canManageUsers = () => hasPermission('can_manage_users')
const canViewReports = () => hasPermission('can_view_reports')
const canExportData = () => hasPermission('can_export_data')
const canManageSettings = () => hasPermission('can_manage_settings')
const canViewDashboard = () => hasPermission("can_view_dashboard");
const canViewHosts = () => hasPermission("can_view_hosts");
const canManageHosts = () => hasPermission("can_manage_hosts");
const canViewPackages = () => hasPermission("can_view_packages");
const canManagePackages = () => hasPermission("can_manage_packages");
const canViewUsers = () => hasPermission("can_view_users");
const canManageUsers = () => hasPermission("can_manage_users");
const canViewReports = () => hasPermission("can_view_reports");
const canExportData = () => hasPermission("can_export_data");
const canManageSettings = () => hasPermission("can_manage_settings");
// Check if any admin users exist (for first-time setup)
const checkAdminUsersExist = useCallback(async () => {
try {
const response = await fetch('/api/v1/auth/check-admin-users', {
method: 'GET',
const response = await fetch("/api/v1/auth/check-admin-users", {
method: "GET",
headers: {
'Content-Type': 'application/json'
}
})
"Content-Type": "application/json",
},
});
if (response.ok) {
const data = await response.json()
setNeedsFirstTimeSetup(!data.hasAdminUsers)
const data = await response.json();
setNeedsFirstTimeSetup(!data.hasAdminUsers);
} else {
// If endpoint doesn't exist or fails, assume setup is needed
setNeedsFirstTimeSetup(true)
setNeedsFirstTimeSetup(true);
}
} catch (error) {
console.error('Error checking admin users:', error)
console.error("Error checking admin users:", error);
// If there's an error, assume setup is needed
setNeedsFirstTimeSetup(true)
setNeedsFirstTimeSetup(true);
} finally {
setCheckingSetup(false)
setCheckingSetup(false);
}
}, [])
}, []);
// Check for admin users on initial load
useEffect(() => {
if (!token && !user) {
checkAdminUsersExist()
checkAdminUsersExist();
} else {
setCheckingSetup(false)
setCheckingSetup(false);
}
}, [token, user, checkAdminUsersExist])
}, [token, user, checkAdminUsersExist]);
const setAuthState = (authToken, authUser) => {
setToken(authToken)
setUser(authUser)
localStorage.setItem('token', authToken)
localStorage.setItem('user', JSON.stringify(authUser))
}
setToken(authToken);
setUser(authUser);
localStorage.setItem("token", authToken);
localStorage.setItem("user", JSON.stringify(authUser));
};
const value = {
user,
@@ -287,12 +296,8 @@ export const AuthProvider = ({ children }) => {
canManageUsers,
canViewReports,
canExportData,
canManageSettings
}
canManageSettings,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

View File

@@ -1,54 +1,52 @@
import React, { createContext, useContext, useEffect, useState } from 'react'
import React, { createContext, useContext, useEffect, useState } from "react";
const ThemeContext = createContext()
const ThemeContext = createContext();
export const useTheme = () => {
const context = useContext(ThemeContext)
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => {
// Check localStorage first, then system preference
const savedTheme = localStorage.getItem('theme')
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
return savedTheme
return savedTheme;
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return 'light'
})
return "light";
});
useEffect(() => {
// Apply theme to document
if (theme === 'dark') {
document.documentElement.classList.add('dark')
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove('dark')
document.documentElement.classList.remove("dark");
}
// Save to localStorage
localStorage.setItem('theme', theme)
}, [theme])
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
}
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
};
const value = {
theme,
toggleTheme,
isDark: theme === 'dark'
}
isDark: theme === "dark",
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
};

View File

@@ -1,58 +1,64 @@
import React, { createContext, useContext, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { versionAPI, settingsAPI } from '../utils/api'
import { useAuth } from './AuthContext'
import { useQuery } from "@tanstack/react-query";
import React, { createContext, useContext, useState } from "react";
import { settingsAPI, versionAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
const UpdateNotificationContext = createContext()
const UpdateNotificationContext = createContext();
export const useUpdateNotification = () => {
const context = useContext(UpdateNotificationContext)
const context = useContext(UpdateNotificationContext);
if (!context) {
throw new Error('useUpdateNotification must be used within an UpdateNotificationProvider')
}
return context
throw new Error(
"useUpdateNotification must be used within an UpdateNotificationProvider",
);
}
return context;
};
export const UpdateNotificationProvider = ({ children }) => {
const [dismissed, setDismissed] = useState(false)
const { user, token } = useAuth()
const [dismissed, setDismissed] = useState(false);
const { user, token } = useAuth();
// Ensure settings are loaded
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ['settings'],
queryFn: () => settingsAPI.get().then(res => res.data),
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
enabled: !!(user && token),
retry: 1
})
retry: 1,
});
// Query for update information
const { data: updateData, isLoading, error } = useQuery({
queryKey: ['updateCheck'],
queryFn: () => versionAPI.checkUpdates().then(res => res.data),
const {
data: updateData,
isLoading,
error,
} = useQuery({
queryKey: ["updateCheck"],
queryFn: () => versionAPI.checkUpdates().then((res) => res.data),
staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
retry: 1,
enabled: !!(user && token && settings && !settingsLoading) // Only run when authenticated and settings are loaded
})
enabled: !!(user && token && settings && !settingsLoading), // Only run when authenticated and settings are loaded
});
const updateAvailable = updateData?.isUpdateAvailable && !dismissed
const updateInfo = updateData
const updateAvailable = updateData?.isUpdateAvailable && !dismissed;
const updateInfo = updateData;
const dismissNotification = () => {
setDismissed(true)
}
setDismissed(true);
};
const value = {
updateAvailable,
updateInfo,
dismissNotification,
isLoading,
error
}
error,
};
return (
<UpdateNotificationContext.Provider value={value}>
{children}
</UpdateNotificationContext.Provider>
)
}
);
};

View File

@@ -1,9 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App.jsx'
import './index.css'
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx";
import "./index.css";
// Create a client for React Query
const queryClient = new QueryClient({
@@ -14,9 +14,9 @@ const queryClient = new QueryClient({
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
})
});
ReactDOM.createRoot(document.getElementById('root')).render(
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
@@ -24,4 +24,4 @@ ReactDOM.createRoot(document.getElementById('root')).render(
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
)
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,97 +1,101 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Plus,
Edit,
Trash2,
Server,
Users,
AlertTriangle,
CheckCircle
} from 'lucide-react'
import { hostGroupsAPI } from '../utils/api'
CheckCircle,
Edit,
Plus,
Server,
Trash2,
Users,
} from "lucide-react";
import React, { useState } from "react";
import { hostGroupsAPI } from "../utils/api";
const 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 [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()
const queryClient = useQueryClient();
// Fetch host groups
const { data: hostGroups, isLoading, error } = useQuery({
queryKey: ['hostGroups'],
queryFn: () => hostGroupsAPI.list().then(res => res.data),
})
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)
queryClient.invalidateQueries(["hostGroups"]);
setShowCreateModal(false);
},
onError: (error) => {
console.error('Failed to create host group:', 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)
queryClient.invalidateQueries(["hostGroups"]);
setShowEditModal(false);
setSelectedGroup(null);
},
onError: (error) => {
console.error('Failed to update host group:', 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)
queryClient.invalidateQueries(["hostGroups"]);
setShowDeleteModal(false);
setGroupToDelete(null);
},
onError: (error) => {
console.error('Failed to delete host group:', error)
}
})
console.error("Failed to delete host group:", error);
},
});
const handleCreate = (data) => {
createMutation.mutate(data)
}
createMutation.mutate(data);
};
const handleEdit = (group) => {
setSelectedGroup(group)
setShowEditModal(true)
}
setSelectedGroup(group);
setShowEditModal(true);
};
const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data })
}
updateMutation.mutate({ id: selectedGroup.id, data });
};
const handleDeleteClick = (group) => {
setGroupToDelete(group)
setShowDeleteModal(true)
}
setGroupToDelete(group);
setShowDeleteModal(true);
};
const handleDeleteConfirm = () => {
deleteMutation.mutate(groupToDelete.id)
}
deleteMutation.mutate(groupToDelete.id);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
);
}
if (error) {
@@ -104,12 +108,12 @@ const HostGroups = () => {
Error loading host groups
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || 'Failed to load host groups'}
{error.message || "Failed to load host groups"}
</p>
</div>
</div>
</div>
)
);
}
return (
@@ -134,7 +138,10 @@ const HostGroups = () => {
{hostGroups && hostGroups.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{hostGroups.map((group) => (
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
<div
key={group.id}
className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
@@ -173,7 +180,10 @@ const HostGroups = () => {
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
<div className="flex items-center gap-1">
<Server className="h-4 w-4" />
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
<span>
{group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}
</span>
</div>
</div>
</div>
@@ -212,8 +222,8 @@ const HostGroups = () => {
<EditHostGroupModal
group={selectedGroup}
onClose={() => {
setShowEditModal(false)
setSelectedGroup(null)
setShowEditModal(false);
setSelectedGroup(null);
}}
onSubmit={handleUpdate}
isLoading={updateMutation.isPending}
@@ -225,36 +235,36 @@ const HostGroups = () => {
<DeleteHostGroupModal
group={groupToDelete}
onClose={() => {
setShowDeleteModal(false)
setGroupToDelete(null)
setShowDeleteModal(false);
setGroupToDelete(null);
}}
onConfirm={handleDeleteConfirm}
isLoading={deleteMutation.isPending}
/>
)}
</div>
)
}
);
};
// Create Host Group Modal
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
name: '',
description: '',
color: '#3B82F6'
})
name: "",
description: "",
color: "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
[e.target.name]: e.target.value,
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
@@ -324,39 +334,35 @@ const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Creating...' : 'Create Group'}
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Creating..." : "Create Group"}
</button>
</div>
</form>
</div>
</div>
)
}
);
};
// Edit Host Group Modal
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
name: group.name,
description: group.description || '',
color: group.color || '#3B82F6'
})
description: group.description || "",
color: group.color || "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
[e.target.name]: e.target.value,
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
@@ -426,19 +432,15 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Updating...' : 'Update Group'}
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Updating..." : "Update Group"}
</button>
</div>
</form>
</div>
</div>
)
}
);
};
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
@@ -461,13 +463,14 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200">
Are you sure you want to delete the host group{' '}
Are you sure you want to delete the host group{" "}
<span className="font-semibold">"{group.name}"</span>?
</p>
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800">
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
<strong>Warning:</strong> This group contains{" "}
{group._count.hosts} host{group._count.hosts !== 1 ? "s" : ""}.
You must move or remove these hosts before deleting the group.
</p>
</div>
@@ -487,12 +490,12 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
className="btn-danger"
disabled={isLoading || group._count.hosts > 0}
>
{isLoading ? 'Deleting...' : 'Delete Group'}
{isLoading ? "Deleting..." : "Delete Group"}
</button>
</div>
</div>
</div>
)
}
);
};
export default HostGroups
export default HostGroups;

File diff suppressed because it is too large Load Diff

View File

@@ -1,173 +1,193 @@
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'
import { authAPI } from '../utils/api'
import {
AlertCircle,
ArrowLeft,
Eye,
EyeOff,
Lock,
Mail,
Smartphone,
User,
} from "lucide-react";
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { authAPI } from "../utils/api";
const Login = () => {
const [isSignupMode, setIsSignupMode] = useState(false)
const [isSignupMode, setIsSignupMode] = useState(false);
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
firstName: '',
lastName: ''
})
username: "",
email: "",
password: "",
firstName: "",
lastName: "",
});
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 [signupEnabled, setSignupEnabled] = useState(false)
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 [signupEnabled, setSignupEnabled] = useState(false);
const navigate = useNavigate()
const { login, setAuthState } = useAuth()
const navigate = useNavigate();
const { login, setAuthState } = useAuth();
// Check if signup is enabled
useEffect(() => {
const checkSignupEnabled = async () => {
try {
const response = await fetch('/api/v1/auth/signup-enabled')
const response = await fetch("/api/v1/auth/signup-enabled");
if (response.ok) {
const data = await response.json()
setSignupEnabled(data.signupEnabled)
const data = await response.json();
setSignupEnabled(data.signupEnabled);
}
} catch (error) {
console.error('Failed to check signup status:', error)
console.error("Failed to check signup status:", error);
// Default to disabled on error for security
setSignupEnabled(false)
setSignupEnabled(false);
}
}
checkSignupEnabled()
}, [])
};
checkSignupEnabled();
}, []);
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
e.preventDefault();
setIsLoading(true);
setError("");
try {
const response = await authAPI.login(formData.username, formData.password)
const response = await authAPI.login(
formData.username,
formData.password,
);
if (response.data.requiresTfa) {
setRequiresTfa(true)
setTfaUsername(formData.username)
setError('')
setRequiresTfa(true);
setTfaUsername(formData.username);
setError("");
} else {
// Regular login successful
const result = await login(formData.username, formData.password)
const result = await login(formData.username, formData.password);
if (result.success) {
navigate('/')
navigate("/");
} else {
setError(result.error || 'Login failed')
setError(result.error || "Login failed");
}
}
} catch (err) {
setError(err.response?.data?.error || 'Login failed')
setError(err.response?.data?.error || "Login failed");
} finally {
setIsLoading(false)
}
setIsLoading(false);
}
};
const handleSignupSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
e.preventDefault();
setIsLoading(true);
setError("");
try {
const response = await authAPI.signup(formData.username, formData.email, formData.password, formData.firstName, formData.lastName)
const response = await authAPI.signup(
formData.username,
formData.email,
formData.password,
formData.firstName,
formData.lastName,
);
if (response.data && response.data.token) {
// Update AuthContext state and localStorage
setAuthState(response.data.token, response.data.user)
setAuthState(response.data.token, response.data.user);
// Redirect to dashboard
navigate('/')
navigate("/");
} else {
setError('Signup failed - invalid response')
setError("Signup failed - invalid response");
}
} catch (err) {
console.error('Signup error:', err)
const errorMessage = err.response?.data?.error ||
console.error("Signup error:", err);
const errorMessage =
err.response?.data?.error ||
(err.response?.data?.errors && err.response.data.errors.length > 0
? err.response.data.errors.map(e => e.msg).join(', ')
: err.message || 'Signup failed')
setError(errorMessage)
? err.response.data.errors.map((e) => e.msg).join(", ")
: err.message || "Signup failed");
setError(errorMessage);
} finally {
setIsLoading(false)
}
setIsLoading(false);
}
};
const handleTfaSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
e.preventDefault();
setIsLoading(true);
setError("");
try {
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token)
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))
localStorage.setItem("token", response.data.token);
localStorage.setItem("user", JSON.stringify(response.data.user));
// Redirect to dashboard
navigate('/')
navigate("/");
} else {
setError('TFA verification failed - invalid response')
setError("TFA verification failed - invalid response");
}
} catch (err) {
console.error('TFA verification error:', err)
const errorMessage = err.response?.data?.error || err.message || 'TFA verification failed'
setError(errorMessage)
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: '' })
setTfaData({ token: "" });
} finally {
setIsLoading(false)
}
setIsLoading(false);
}
};
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
[e.target.name]: e.target.value,
});
};
const handleTfaInputChange = (e) => {
setTfaData({
...tfaData,
[e.target.name]: e.target.value.replace(/\D/g, '').slice(0, 6)
})
[e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6),
});
// Clear error when user starts typing
if (error) {
setError('')
}
setError("");
}
};
const handleBackToLogin = () => {
setRequiresTfa(false)
setTfaData({ token: '' })
setError('')
}
setRequiresTfa(false);
setTfaData({ token: "" });
setError("");
};
const toggleMode = () => {
// Only allow signup mode if signup is enabled
if (!signupEnabled && !isSignupMode) {
return // Don't allow switching to signup if disabled
return; // Don't allow switching to signup if disabled
}
setIsSignupMode(!isSignupMode)
setIsSignupMode(!isSignupMode);
setFormData({
username: '',
email: '',
password: '',
firstName: '',
lastName: ''
})
setError('')
}
username: "",
email: "",
password: "",
firstName: "",
lastName: "",
});
setError("");
};
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
@@ -177,7 +197,7 @@ const Login = () => {
<Lock size={24} color="#2563eb" strokeWidth={2} />
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
{isSignupMode ? 'Create PatchMon Account' : 'Sign in to PatchMon'}
{isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"}
</h2>
<p className="mt-2 text-center text-sm text-secondary-600">
Monitor and manage your Linux package updates
@@ -185,11 +205,17 @@ const Login = () => {
</div>
{!requiresTfa ? (
<form className="mt-8 space-y-6" onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}>
<form
className="mt-8 space-y-6"
onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}
>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
{isSignupMode ? 'Username' : 'Username or Email'}
<label
htmlFor="username"
className="block text-sm font-medium text-secondary-700"
>
{isSignupMode ? "Username" : "Username or Email"}
</label>
<div className="mt-1 relative">
<input
@@ -200,14 +226,14 @@ const Login = () => {
value={formData.username}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder={isSignupMode ? "Enter your username" : "Enter your username or email"}
placeholder={
isSignupMode
? "Enter your username"
: "Enter your username or email"
}
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<User
size={20}
color="#64748b"
strokeWidth={2}
/>
<User size={20} color="#64748b" strokeWidth={2} />
</div>
</div>
</div>
@@ -216,7 +242,10 @@ const Login = () => {
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-secondary-700">
<label
htmlFor="firstName"
className="block text-sm font-medium text-secondary-700"
>
First Name
</label>
<div className="mt-1 relative">
@@ -236,7 +265,10 @@ const Login = () => {
</div>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700">
<label
htmlFor="lastName"
className="block text-sm font-medium text-secondary-700"
>
Last Name
</label>
<div className="mt-1 relative">
@@ -257,7 +289,10 @@ const Login = () => {
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary-700">
<label
htmlFor="email"
className="block text-sm font-medium text-secondary-700"
>
Email
</label>
<div className="mt-1 relative">
@@ -272,11 +307,7 @@ const Login = () => {
placeholder="Enter your email"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<Mail
size={20}
color="#64748b"
strokeWidth={2}
/>
<Mail size={20} color="#64748b" strokeWidth={2} />
</div>
</div>
</div>
@@ -284,14 +315,17 @@ const Login = () => {
)}
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary-700">
<label
htmlFor="password"
className="block text-sm font-medium text-secondary-700"
>
Password
</label>
<div className="mt-1 relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
type={showPassword ? "text" : "password"}
required
value={formData.password}
onChange={handleInputChange}
@@ -299,11 +333,7 @@ const Login = () => {
placeholder="Enter your password"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<Lock
size={20}
color="#64748b"
strokeWidth={2}
/>
<Lock size={20} color="#64748b" strokeWidth={2} />
</div>
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
<button
@@ -342,10 +372,12 @@ const Login = () => {
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
{isSignupMode ? 'Creating account...' : 'Signing in...'}
{isSignupMode ? "Creating account..." : "Signing in..."}
</div>
) : isSignupMode ? (
"Create Account"
) : (
isSignupMode ? 'Create Account' : 'Sign in'
"Sign in"
)}
</button>
</div>
@@ -353,13 +385,15 @@ const Login = () => {
{signupEnabled && (
<div className="text-center">
<p className="text-sm text-secondary-600">
{isSignupMode ? 'Already have an account?' : "Don't have an account?"}{' '}
{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'}
{isSignupMode ? "Sign in" : "Sign up"}
</button>
</p>
</div>
@@ -380,7 +414,10 @@ const Login = () => {
</div>
<div>
<label htmlFor="token" className="block text-sm font-medium text-secondary-700">
<label
htmlFor="token"
className="block text-sm font-medium text-secondary-700"
>
Verification Code
</label>
<div className="mt-1">
@@ -421,7 +458,7 @@ const Login = () => {
Verifying...
</div>
) : (
'Verify Code'
"Verify Code"
)}
</button>
@@ -444,7 +481,7 @@ const Login = () => {
)}
</div>
</div>
)
}
);
};
export default Login
export default Login;

View File

@@ -1,98 +1,107 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Plus,
Edit,
Trash2,
Server,
Users,
AlertTriangle,
CheckCircle,
Settings
} from 'lucide-react'
import { hostGroupsAPI } from '../utils/api'
Edit,
Plus,
Server,
Settings,
Trash2,
Users,
} from "lucide-react";
import React, { useState } from "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 [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()
const queryClient = useQueryClient();
// Tab configuration
const tabs = [
{ id: 'hostgroups', name: 'Host Groups', icon: Users },
{ id: 'notifications', name: 'Notifications', icon: AlertTriangle, comingSoon: true }
]
{ 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),
})
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)
queryClient.invalidateQueries(["hostGroups"]);
setShowCreateModal(false);
},
onError: (error) => {
console.error('Failed to create host group:', 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)
queryClient.invalidateQueries(["hostGroups"]);
setShowEditModal(false);
setSelectedGroup(null);
},
onError: (error) => {
console.error('Failed to update host group:', 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)
queryClient.invalidateQueries(["hostGroups"]);
setShowDeleteModal(false);
setGroupToDelete(null);
},
onError: (error) => {
console.error('Failed to delete host group:', error)
}
})
console.error("Failed to delete host group:", error);
},
});
const handleCreate = (data) => {
createMutation.mutate(data)
}
createMutation.mutate(data);
};
const handleEdit = (group) => {
setSelectedGroup(group)
setShowEditModal(true)
}
setSelectedGroup(group);
setShowEditModal(true);
};
const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data })
}
updateMutation.mutate({ id: selectedGroup.id, data });
};
const handleDeleteClick = (group) => {
setGroupToDelete(group)
setShowDeleteModal(true)
}
setGroupToDelete(group);
setShowDeleteModal(true);
};
const handleDeleteConfirm = () => {
deleteMutation.mutate(groupToDelete.id)
}
deleteMutation.mutate(groupToDelete.id);
};
const renderHostGroupsTab = () => {
if (isLoading) {
@@ -100,7 +109,7 @@ const Options = () => {
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
);
}
if (error) {
@@ -113,12 +122,12 @@ const Options = () => {
Error loading host groups
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || 'Failed to load host groups'}
{error.message || "Failed to load host groups"}
</p>
</div>
</div>
</div>
)
);
}
return (
@@ -146,7 +155,10 @@ const Options = () => {
{hostGroups && hostGroups.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{hostGroups.map((group) => (
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
<div
key={group.id}
className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
@@ -185,7 +197,10 @@ const Options = () => {
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
<div className="flex items-center gap-1">
<Server className="h-4 w-4" />
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
<span>
{group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}
</span>
</div>
</div>
</div>
@@ -210,8 +225,8 @@ const Options = () => {
</div>
)}
</div>
)
}
);
};
const renderComingSoonTab = (tabName) => (
<div className="text-center py-12">
@@ -220,10 +235,11 @@ const Options = () => {
{tabName} Coming Soon
</h3>
<p className="text-secondary-600 dark:text-secondary-300">
This feature is currently under development and will be available in a future update.
This feature is currently under development and will be available in a
future update.
</p>
</div>
)
);
return (
<div className="space-y-6">
@@ -241,15 +257,15 @@ const Options = () => {
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const Icon = tab.icon
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === tab.id
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300'
? "border-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300"
}`}
>
<Icon className="h-4 w-4" />
@@ -260,15 +276,15 @@ const Options = () => {
</span>
)}
</button>
)
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="mt-6">
{activeTab === 'hostgroups' && renderHostGroupsTab()}
{activeTab === 'notifications' && renderComingSoonTab('Notifications')}
{activeTab === "hostgroups" && renderHostGroupsTab()}
{activeTab === "notifications" && renderComingSoonTab("Notifications")}
</div>
{/* Create Modal */}
@@ -285,8 +301,8 @@ const Options = () => {
<EditHostGroupModal
group={selectedGroup}
onClose={() => {
setShowEditModal(false)
setSelectedGroup(null)
setShowEditModal(false);
setSelectedGroup(null);
}}
onSubmit={handleUpdate}
isLoading={updateMutation.isPending}
@@ -298,36 +314,36 @@ const Options = () => {
<DeleteHostGroupModal
group={groupToDelete}
onClose={() => {
setShowDeleteModal(false)
setGroupToDelete(null)
setShowDeleteModal(false);
setGroupToDelete(null);
}}
onConfirm={handleDeleteConfirm}
isLoading={deleteMutation.isPending}
/>
)}
</div>
)
}
);
};
// Create Host Group Modal
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
name: '',
description: '',
color: '#3B82F6'
})
name: "",
description: "",
color: "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
[e.target.name]: e.target.value,
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
@@ -397,39 +413,35 @@ const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Creating...' : 'Create Group'}
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Creating..." : "Create Group"}
</button>
</div>
</form>
</div>
</div>
)
}
);
};
// Edit Host Group Modal
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
name: group.name,
description: group.description || '',
color: group.color || '#3B82F6'
})
description: group.description || "",
color: group.color || "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
[e.target.name]: e.target.value,
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
@@ -499,19 +511,15 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Updating...' : 'Update Group'}
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Updating..." : "Update Group"}
</button>
</div>
</form>
</div>
</div>
)
}
);
};
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
@@ -534,13 +542,14 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200">
Are you sure you want to delete the host group{' '}
Are you sure you want to delete the host group{" "}
<span className="font-semibold">"{group.name}"</span>?
</p>
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800">
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
<strong>Warning:</strong> This group contains{" "}
{group._count.hosts} host{group._count.hosts !== 1 ? "s" : ""}.
You must move or remove these hosts before deleting the group.
</p>
</div>
@@ -560,12 +569,12 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
className="btn-danger"
disabled={isLoading || group._count.hosts > 0}
>
{isLoading ? 'Deleting...' : 'Delete Group'}
{isLoading ? "Deleting..." : "Delete Group"}
</button>
</div>
</div>
</div>
)
}
);
};
export default Options
export default Options;

View File

@@ -1,25 +1,27 @@
import React from 'react'
import { useParams } from 'react-router-dom'
import { Package } from 'lucide-react'
import { Package } from "lucide-react";
import React from "react";
import { useParams } from "react-router-dom";
const PackageDetail = () => {
const { packageId } = useParams()
const { packageId } = useParams();
return (
<div className="space-y-6">
<div className="card p-8 text-center">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 mb-2">Package Details</h3>
<h3 className="text-lg font-medium text-secondary-900 mb-2">
Package Details
</h3>
<p className="text-secondary-600">
Detailed view for package: {packageId}
</p>
<p className="text-secondary-600 mt-2">
This page will show package information, affected hosts, version distribution, and more.
This page will show package information, affected hosts, version
distribution, and more.
</p>
</div>
</div>
)
}
);
};
export default PackageDetail
export default PackageDetail;

View File

@@ -1,227 +1,252 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronDown,
Columns,
ExternalLink,
Eye as EyeIcon,
EyeOff as EyeOffIcon,
Filter,
GripVertical,
Package,
Server,
Shield,
RefreshCw,
Search,
AlertTriangle,
Filter,
ExternalLink,
ArrowUpDown,
ArrowUp,
ArrowDown,
ChevronDown,
Server,
Settings,
Columns,
GripVertical,
Shield,
X,
Eye as EyeIcon,
EyeOff as EyeOffIcon
} from 'lucide-react'
import { dashboardAPI } from '../utils/api'
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { dashboardAPI } from "../utils/api";
const Packages = () => {
const [searchTerm, setSearchTerm] = useState('')
const [categoryFilter, setCategoryFilter] = useState('all')
const [securityFilter, setSecurityFilter] = useState('all')
const [hostFilter, setHostFilter] = useState('all')
const [sortField, setSortField] = useState('name')
const [sortDirection, setSortDirection] = useState('asc')
const [showColumnSettings, setShowColumnSettings] = useState(false)
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const [securityFilter, setSecurityFilter] = useState("all");
const [hostFilter, setHostFilter] = useState("all");
const [sortField, setSortField] = useState("name");
const [sortDirection, setSortDirection] = useState("asc");
const [showColumnSettings, setShowColumnSettings] = useState(false);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
// Handle host filter from URL parameter
useEffect(() => {
const hostParam = searchParams.get('host')
const hostParam = searchParams.get("host");
if (hostParam) {
setHostFilter(hostParam)
setHostFilter(hostParam);
}
}, [searchParams])
}, [searchParams]);
// Column configuration
const [columnConfig, setColumnConfig] = useState(() => {
const defaultConfig = [
{ id: 'name', label: 'Package', visible: true, order: 0 },
{ id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
{ id: 'priority', label: 'Priority', visible: true, order: 2 },
{ id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
]
{ id: "name", label: "Package", visible: true, order: 0 },
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
{ id: "priority", label: "Priority", visible: true, order: 2 },
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
];
const saved = localStorage.getItem('packages-column-config')
const saved = localStorage.getItem("packages-column-config");
if (saved) {
const savedConfig = JSON.parse(saved)
const savedConfig = JSON.parse(saved);
// Merge with defaults to handle new columns
return defaultConfig.map(defaultCol => {
const savedCol = savedConfig.find(col => col.id === defaultCol.id)
return savedCol ? { ...defaultCol, ...savedCol } : defaultCol
})
return defaultConfig.map((defaultCol) => {
const savedCol = savedConfig.find((col) => col.id === defaultCol.id);
return savedCol ? { ...defaultCol, ...savedCol } : defaultCol;
});
}
return defaultConfig
})
return defaultConfig;
});
// Update column configuration
const updateColumnConfig = (newConfig) => {
setColumnConfig(newConfig)
localStorage.setItem('packages-column-config', JSON.stringify(newConfig))
}
setColumnConfig(newConfig);
localStorage.setItem("packages-column-config", JSON.stringify(newConfig));
};
// Handle affected hosts click
const handleAffectedHostsClick = (pkg) => {
const affectedHosts = pkg.affectedHosts || []
const hostIds = affectedHosts.map(host => host.hostId)
const hostNames = 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()
params.set('selected', hostIds.join(','))
params.set('filter', 'selected')
const params = new URLSearchParams();
params.set("selected", hostIds.join(","));
params.set("filter", "selected");
// Navigate to hosts page with selected hosts
navigate(`/hosts?${params.toString()}`)
}
navigate(`/hosts?${params.toString()}`);
};
// Handle URL filter parameters
useEffect(() => {
const filter = searchParams.get('filter')
if (filter === 'outdated') {
const filter = searchParams.get("filter");
if (filter === "outdated") {
// For outdated packages, we want to show all packages that need updates
// This is the default behavior, so we don't need to change filters
setCategoryFilter('all')
setSecurityFilter('all')
} else if (filter === 'security') {
setCategoryFilter("all");
setSecurityFilter("all");
} else if (filter === "security") {
// For security updates, filter to show only security updates
setSecurityFilter('security')
setCategoryFilter('all')
setSecurityFilter("security");
setCategoryFilter("all");
}
}, [searchParams])
}, [searchParams]);
const { data: packages, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['packages'],
queryFn: () => dashboardAPI.getPackages().then(res => res.data),
const {
data: packages,
isLoading,
error,
refetch,
isFetching,
} = useQuery({
queryKey: ["packages"],
queryFn: () => dashboardAPI.getPackages().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
});
// Fetch hosts data to get total packages count
const { data: hosts } = useQuery({
queryKey: ['hosts'],
queryFn: () => dashboardAPI.getHosts().then(res => res.data),
queryKey: ["hosts"],
queryFn: () => dashboardAPI.getHosts().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
});
// Filter and sort packages
const filteredAndSortedPackages = useMemo(() => {
if (!packages) return []
if (!packages) return [];
// Filter packages
const filtered = packages.filter(pkg => {
const matchesSearch = pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(pkg.description && pkg.description.toLowerCase().includes(searchTerm.toLowerCase()))
const filtered = packages.filter((pkg) => {
const matchesSearch =
pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(pkg.description &&
pkg.description.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
const matchesCategory =
categoryFilter === "all" || pkg.category === categoryFilter;
const matchesSecurity = securityFilter === 'all' ||
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
const matchesSecurity =
securityFilter === "all" ||
(securityFilter === "security" && pkg.isSecurityUpdate) ||
(securityFilter === "regular" && !pkg.isSecurityUpdate);
const affectedHosts = pkg.affectedHosts || []
const matchesHost = hostFilter === 'all' ||
affectedHosts.some(host => host.hostId === hostFilter)
const affectedHosts = pkg.affectedHosts || [];
const matchesHost =
hostFilter === "all" ||
affectedHosts.some((host) => host.hostId === hostFilter);
return matchesSearch && matchesCategory && matchesSecurity && matchesHost
})
return matchesSearch && matchesCategory && matchesSecurity && matchesHost;
});
// Sorting
filtered.sort((a, b) => {
let aValue, bValue
let aValue, bValue;
switch (sortField) {
case 'name':
aValue = a.name?.toLowerCase() || ''
bValue = b.name?.toLowerCase() || ''
break
case 'latestVersion':
aValue = a.latestVersion?.toLowerCase() || ''
bValue = b.latestVersion?.toLowerCase() || ''
break
case 'affectedHosts':
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
bValue = b.isSecurityUpdate ? 0 : 1
break
case "name":
aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || "";
break;
case "latestVersion":
aValue = a.latestVersion?.toLowerCase() || "";
bValue = b.latestVersion?.toLowerCase() || "";
break;
case "affectedHosts":
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
bValue = b.isSecurityUpdate ? 0 : 1;
break;
default:
aValue = a.name?.toLowerCase() || ''
bValue = b.name?.toLowerCase() || ''
aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || "";
}
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1
return 0
})
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
return 0;
});
return filtered
}, [packages, searchTerm, categoryFilter, securityFilter, sortField, sortDirection])
return filtered;
}, [
packages,
searchTerm,
categoryFilter,
securityFilter,
sortField,
sortDirection,
]);
// Get visible columns in order
const visibleColumns = columnConfig
.filter(col => col.visible)
.sort((a, b) => a.order - b.order)
.filter((col) => col.visible)
.sort((a, b) => a.order - b.order);
// Sorting functions
const handleSort = (field) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortField(field)
setSortDirection('asc')
}
setSortField(field);
setSortDirection("asc");
}
};
const getSortIcon = (field) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />
return sortDirection === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
}
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />;
return sortDirection === "asc" ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
);
};
// Column management functions
const toggleColumnVisibility = (columnId) => {
const newConfig = columnConfig.map(col =>
col.id === columnId ? { ...col, visible: !col.visible } : col
)
updateColumnConfig(newConfig)
}
const newConfig = columnConfig.map((col) =>
col.id === columnId ? { ...col, visible: !col.visible } : col,
);
updateColumnConfig(newConfig);
};
const reorderColumns = (fromIndex, toIndex) => {
const newConfig = [...columnConfig]
const [movedColumn] = newConfig.splice(fromIndex, 1)
newConfig.splice(toIndex, 0, movedColumn)
const newConfig = [...columnConfig];
const [movedColumn] = newConfig.splice(fromIndex, 1);
newConfig.splice(toIndex, 0, movedColumn);
// Update order values
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
updateColumnConfig(updatedConfig)
}
const updatedConfig = newConfig.map((col, index) => ({
...col,
order: index,
}));
updateColumnConfig(updatedConfig);
};
const resetColumns = () => {
const defaultConfig = [
{ id: 'name', label: 'Package', visible: true, order: 0 },
{ id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
{ id: 'priority', label: 'Priority', visible: true, order: 2 },
{ id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
]
updateColumnConfig(defaultConfig)
}
{ id: "name", label: "Package", visible: true, order: 0 },
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
{ id: "priority", label: "Priority", visible: true, order: 2 },
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
];
updateColumnConfig(defaultConfig);
};
// Helper function to render table cell content
const renderCellContent = (column, pkg) => {
switch (column.id) {
case 'name':
case "name":
return (
<div className="flex items-center">
<Package className="h-5 w-5 text-secondary-400 mr-3" />
@@ -241,9 +266,10 @@ const Packages = () => {
)}
</div>
</div>
)
case 'affectedHosts':
const affectedHostsCount = pkg.affectedHostsCount || pkg.affectedHosts?.length || 0
);
case "affectedHosts": {
const affectedHostsCount =
pkg.affectedHostsCount || pkg.affectedHosts?.length || 0;
return (
<button
onClick={() => handleAffectedHostsClick(pkg)}
@@ -251,11 +277,12 @@ const Packages = () => {
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">
{affectedHostsCount} host{affectedHostsCount !== 1 ? 's' : ''}
{affectedHostsCount} host{affectedHostsCount !== 1 ? "s" : ""}
</div>
</button>
)
case 'priority':
);
}
case "priority":
return pkg.isSecurityUpdate ? (
<span className="badge-danger flex items-center gap-1">
<Shield className="h-3 w-3" />
@@ -263,61 +290,68 @@ const Packages = () => {
</span>
) : (
<span className="badge-warning">Regular Update</span>
)
case 'latestVersion':
);
case "latestVersion":
return (
<div className="text-sm text-secondary-900 dark:text-white max-w-xs truncate" title={pkg.latestVersion || 'Unknown'}>
{pkg.latestVersion || 'Unknown'}
<div
className="text-sm text-secondary-900 dark:text-white max-w-xs truncate"
title={pkg.latestVersion || "Unknown"}
>
{pkg.latestVersion || "Unknown"}
</div>
)
);
default:
return null
}
return null;
}
};
// Get unique categories
const categories = [...new Set(packages?.map(pkg => pkg.category).filter(Boolean))] || []
const categories =
[...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || [];
// Calculate unique affected hosts
const uniqueAffectedHosts = new Set()
packages?.forEach(pkg => {
const affectedHosts = pkg.affectedHosts || []
affectedHosts.forEach(host => {
uniqueAffectedHosts.add(host.hostId)
})
})
const uniqueAffectedHostsCount = uniqueAffectedHosts.size
const uniqueAffectedHosts = new Set();
packages?.forEach((pkg) => {
const affectedHosts = pkg.affectedHosts || [];
affectedHosts.forEach((host) => {
uniqueAffectedHosts.add(host.hostId);
});
});
const uniqueAffectedHostsCount = uniqueAffectedHosts.size;
// Calculate total packages across all hosts (including up-to-date ones)
const totalPackagesCount = hosts?.reduce((total, host) => {
return total + (host.totalPackagesCount || 0)
}, 0) || 0
const totalPackagesCount =
hosts?.reduce((total, host) => {
return total + (host.totalPackagesCount || 0);
}, 0) || 0;
// Calculate outdated packages (packages that need updates)
const outdatedPackagesCount = packages?.length || 0
const outdatedPackagesCount = packages?.length || 0;
// Calculate security updates
const securityUpdatesCount = packages?.filter(pkg => pkg.isSecurityUpdate).length || 0
const securityUpdatesCount =
packages?.filter((pkg) => pkg.isSecurityUpdate).length || 0;
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
</div>
)
);
}
if (error) {
return (
<div className="space-y-6">
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">Error loading packages</h3>
<h3 className="text-sm font-medium text-danger-800">
Error loading packages
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || 'Failed to load packages'}
{error.message || "Failed to load packages"}
</p>
<button
onClick={() => refetch()}
@@ -329,7 +363,7 @@ const Packages = () => {
</div>
</div>
</div>
)
);
}
return (
@@ -337,7 +371,9 @@ const Packages = () => {
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Packages</h1>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
Packages
</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Manage package updates and security patches
</p>
@@ -349,8 +385,10 @@ const Packages = () => {
className="btn-outline flex items-center gap-2"
title="Refresh packages data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
{isFetching ? "Refreshing..." : "Refresh"}
</button>
</div>
</div>
@@ -361,8 +399,12 @@ const Packages = () => {
<div className="flex items-center">
<Package className="h-5 w-5 text-primary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Total Packages</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{totalPackagesCount}</p>
<p className="text-sm text-secondary-500 dark:text-white">
Total Packages
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{totalPackagesCount}
</p>
</div>
</div>
</div>
@@ -371,7 +413,9 @@ const Packages = () => {
<div className="flex items-center">
<Package className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Total Outdated Packages</p>
<p className="text-sm text-secondary-500 dark:text-white">
Total Outdated Packages
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{outdatedPackagesCount}
</p>
@@ -383,7 +427,9 @@ const Packages = () => {
<div className="flex items-center">
<Server className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Hosts Pending Updates</p>
<p className="text-sm text-secondary-500 dark:text-white">
Hosts Pending Updates
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{uniqueAffectedHostsCount}
</p>
@@ -395,8 +441,12 @@ const Packages = () => {
<div className="flex items-center">
<Shield className="h-5 w-5 text-danger-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Security Updates Across All Hosts</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{securityUpdatesCount}</p>
<p className="text-sm text-secondary-500 dark:text-white">
Security Updates Across All Hosts
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{securityUpdatesCount}
</p>
</div>
</div>
</div>
@@ -434,8 +484,10 @@ const Packages = () => {
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
@@ -461,8 +513,10 @@ const Packages = () => {
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Hosts</option>
{hosts?.map(host => (
<option key={host.id} value={host.id}>{host.friendly_name}</option>
{hosts?.map((host) => (
<option key={host.id} value={host.id}>
{host.friendly_name}
</option>
))}
</select>
</div>
@@ -485,7 +539,9 @@ const Packages = () => {
<div className="text-center py-8">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{packages?.length === 0 ? 'No packages need updates' : 'No packages match your filters'}
{packages?.length === 0
? "No packages need updates"
: "No packages match your filters"}
</p>
{packages?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
@@ -499,7 +555,10 @@ const Packages = () => {
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
<tr>
{visibleColumns.map((column) => (
<th key={column.id} className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<th
key={column.id}
className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
>
<button
onClick={() => handleSort(column.id)}
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
@@ -513,9 +572,15 @@ const Packages = () => {
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredAndSortedPackages.map((pkg) => (
<tr key={pkg.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
<tr
key={pkg.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
>
{visibleColumns.map((column) => (
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
<td
key={column.id}
className="px-4 py-2 whitespace-nowrap text-center"
>
{renderCellContent(column, pkg)}
</td>
))}
@@ -540,37 +605,48 @@ const Packages = () => {
/>
)}
</div>
)
}
);
};
// Column Settings Modal Component
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
const [draggedIndex, setDraggedIndex] = useState(null)
const ColumnSettingsModal = ({
columnConfig,
onClose,
onToggleVisibility,
onReorder,
onReset,
}) => {
const [draggedIndex, setDraggedIndex] = useState(null);
const handleDragStart = (e, index) => {
setDraggedIndex(index)
e.dataTransfer.effectAllowed = 'move'
}
setDraggedIndex(index);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
};
const handleDrop = (e, dropIndex) => {
e.preventDefault()
e.preventDefault();
if (draggedIndex !== null && draggedIndex !== dropIndex) {
onReorder(draggedIndex, dropIndex)
}
setDraggedIndex(null)
onReorder(draggedIndex, dropIndex);
}
setDraggedIndex(null);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Customize Columns</h3>
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Customize Columns
</h3>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
@@ -584,7 +660,9 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
className={`flex items-center justify-between p-3 border rounded-lg cursor-move ${
draggedIndex === index ? 'opacity-50' : 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
draggedIndex === index
? "opacity-50"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
} border-secondary-200 dark:border-secondary-600`}
>
<div className="flex items-center gap-3">
@@ -597,11 +675,15 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
onClick={() => onToggleVisibility(column.id)}
className={`p-1 rounded ${
column.visible
? 'text-primary-600 hover:text-primary-700'
: 'text-secondary-400 hover:text-secondary-600'
? "text-primary-600 hover:text-primary-700"
: "text-secondary-400 hover:text-secondary-600"
}`}
>
{column.visible ? <EyeIcon className="h-4 w-4" /> : <EyeOffIcon className="h-4 w-4" />}
{column.visible ? (
<EyeIcon className="h-4 w-4" />
) : (
<EyeOffIcon className="h-4 w-4" />
)}
</button>
</div>
))}
@@ -623,7 +705,7 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
</div>
</div>
</div>
)
}
);
};
export default Packages
export default Packages;

View File

@@ -1,80 +1,89 @@
import React, { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Shield,
Settings,
Users,
Server,
Package,
AlertTriangle,
BarChart3,
Download,
Eye,
Edit,
Trash2,
Eye,
Package,
Plus,
RefreshCw,
Save,
Server,
Settings,
Shield,
Trash2,
Users,
X,
AlertTriangle,
RefreshCw
} from 'lucide-react'
import { permissionsAPI } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
} from "lucide-react";
import React, { useEffect, useState } from "react";
import { useAuth } from "../contexts/AuthContext";
import { permissionsAPI } from "../utils/api";
const Permissions = () => {
const [editingRole, setEditingRole] = useState(null)
const [showAddModal, setShowAddModal] = useState(false)
const queryClient = useQueryClient()
const { refreshPermissions } = useAuth()
const [editingRole, setEditingRole] = useState(null);
const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient();
const { refreshPermissions } = useAuth();
// Fetch all role permissions
const { data: roles, isLoading, error } = useQuery({
queryKey: ['rolePermissions'],
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
})
const {
data: roles,
isLoading,
error,
} = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update role permissions mutation
const updateRoleMutation = useMutation({
mutationFn: ({ role, permissions }) => permissionsAPI.updateRole(role, permissions),
mutationFn: ({ role, permissions }) =>
permissionsAPI.updateRole(role, permissions),
onSuccess: () => {
queryClient.invalidateQueries(['rolePermissions'])
setEditingRole(null)
queryClient.invalidateQueries(["rolePermissions"]);
setEditingRole(null);
// Refresh user permissions to apply changes immediately
refreshPermissions()
}
})
refreshPermissions();
},
});
// Delete role mutation
const deleteRoleMutation = useMutation({
mutationFn: (role) => permissionsAPI.deleteRole(role),
onSuccess: () => {
queryClient.invalidateQueries(['rolePermissions'])
}
})
queryClient.invalidateQueries(["rolePermissions"]);
},
});
const handleSavePermissions = async (role, permissions) => {
try {
await updateRoleMutation.mutateAsync({ role, permissions })
await updateRoleMutation.mutateAsync({ role, permissions });
} catch (error) {
console.error('Failed to update permissions:', error)
}
console.error("Failed to update permissions:", error);
}
};
const handleDeleteRole = async (role) => {
if (window.confirm(`Are you sure you want to delete the "${role}" role? This action cannot be undone.`)) {
if (
window.confirm(
`Are you sure you want to delete the "${role}" role? This action cannot be undone.`,
)
) {
try {
await deleteRoleMutation.mutateAsync(role)
await deleteRoleMutation.mutateAsync(role);
} catch (error) {
console.error('Failed to delete role:', error)
}
console.error("Failed to delete role:", error);
}
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
);
}
if (error) {
@@ -83,12 +92,14 @@ const Permissions = () => {
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">Error loading permissions</h3>
<h3 className="text-sm font-medium text-danger-800">
Error loading permissions
</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
)
);
}
return (
@@ -115,7 +126,9 @@ const Permissions = () => {
{/* Roles List */}
<div className="space-y-4">
{roles && Array.isArray(roles) && roles.map((role) => (
{roles &&
Array.isArray(roles) &&
roles.map((role) => (
<RolePermissionsCard
key={role.id}
role={role}
@@ -133,48 +146,105 @@ const Permissions = () => {
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={() => {
queryClient.invalidateQueries(['rolePermissions'])
setShowAddModal(false)
queryClient.invalidateQueries(["rolePermissions"]);
setShowAddModal(false);
}}
/>
</div>
)
}
);
};
// Role Permissions Card Component
const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => {
const [permissions, setPermissions] = useState(role)
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])
setPermissions(role);
}, [role]);
const permissionFields = [
{ 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' }
]
{
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) => {
setPermissions(prev => ({
setPermissions((prev) => ({
...prev,
[key]: value
}))
}
[key]: value,
}));
};
const handleSave = () => {
onSave(role.role, permissions)
}
onSave(role.role, permissions);
};
const isBuiltInRole = role.role === 'admin' || role.role === 'user'
const isBuiltInRole = role.role === "admin" || role.role === "user";
return (
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
@@ -182,7 +252,9 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
<div className="flex items-center justify-between">
<div className="flex items-center">
<Shield className="h-5 w-5 text-primary-600 mr-3" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">{role.role}</h3>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">
{role.role}
</h3>
{isBuiltInRole && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
Built-in Role
@@ -235,8 +307,8 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
<div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{permissionFields.map((field) => {
const Icon = field.icon
const isChecked = permissions[field.key]
const Icon = field.icon;
const isChecked = permissions[field.key];
return (
<div key={field.key} className="flex items-start">
@@ -244,8 +316,13 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
<input
type="checkbox"
checked={isChecked}
onChange={(e) => handlePermissionChange(field.key, e.target.checked)}
disabled={!isEditing || (isBuiltInRole && field.key === 'can_manage_users')}
onChange={(e) =>
handlePermissionChange(field.key, e.target.checked)
}
disabled={
!isEditing ||
(isBuiltInRole && 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>
@@ -261,18 +338,18 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
</p>
</div>
</div>
)
);
})}
</div>
</div>
</div>
)
}
);
};
// Add Role Modal Component
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
role: '',
role: "",
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: false,
@@ -282,40 +359,42 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
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('')
can_manage_settings: false,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
e.preventDefault();
setIsLoading(true);
setError("");
try {
await permissionsAPI.updateRole(formData.role, formData)
onSuccess()
await permissionsAPI.updateRole(formData.role, formData);
onSuccess();
} catch (err) {
setError(err.response?.data?.error || 'Failed to create role')
setError(err.response?.data?.error || "Failed to create role");
} finally {
setIsLoading(false)
}
setIsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value
})
}
[name]: type === "checkbox" ? checked : value,
});
};
if (!isOpen) return null
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-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>
<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>
@@ -331,22 +410,26 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
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 dark:text-secondary-400">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>
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">
Permissions
</h4>
{[
{ 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' }
{ 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
@@ -365,7 +448,9 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
@@ -382,13 +467,13 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create Role'}
{isLoading ? "Creating..." : "Create Role"}
</button>
</div>
</form>
</div>
</div>
)
}
);
};
export default Permissions
export default Permissions;

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,55 @@
import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowDown,
ArrowUp,
ArrowUpDown,
Check,
Columns,
Database,
Eye,
Globe,
GripVertical,
Lock,
RefreshCw,
Search,
Server,
Shield,
ShieldCheck,
AlertTriangle,
Users,
Globe,
Lock,
Unlock,
Database,
Eye,
Search,
Columns,
ArrowUpDown,
ArrowUp,
ArrowDown,
Users,
X,
GripVertical,
Check,
RefreshCw
} from 'lucide-react';
import { repositoryAPI } from '../utils/api';
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { repositoryAPI } from "../utils/api";
const Repositories = () => {
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all'); // all, secure, insecure
const [filterStatus, setFilterStatus] = useState('all'); // all, active, inactive
const [sortField, setSortField] = useState('name');
const [sortDirection, setSortDirection] = useState('asc');
const [searchTerm, setSearchTerm] = useState("");
const [filterType, setFilterType] = useState("all"); // all, secure, insecure
const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive
const [sortField, setSortField] = useState("name");
const [sortDirection, setSortDirection] = useState("asc");
const [showColumnSettings, setShowColumnSettings] = useState(false);
// Column configuration
const [columnConfig, setColumnConfig] = useState(() => {
const defaultConfig = [
{ id: 'name', label: 'Repository', visible: true, order: 0 },
{ id: 'url', label: 'URL', visible: true, order: 1 },
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 },
{ id: 'security', label: 'Security', visible: true, order: 3 },
{ id: 'status', label: 'Status', visible: true, order: 4 },
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
{ id: 'actions', label: 'Actions', visible: true, order: 6 }
{ id: "name", label: "Repository", visible: true, order: 0 },
{ id: "url", label: "URL", visible: true, order: 1 },
{ id: "distribution", label: "Distribution", visible: true, order: 2 },
{ id: "security", label: "Security", visible: true, order: 3 },
{ id: "status", label: "Status", visible: true, order: 4 },
{ id: "hostCount", label: "Hosts", visible: true, order: 5 },
{ id: "actions", label: "Actions", visible: true, order: 6 },
];
const saved = localStorage.getItem('repositories-column-config');
const saved = localStorage.getItem("repositories-column-config");
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.error('Failed to parse saved column config:', e);
console.error("Failed to parse saved column config:", e);
}
}
return defaultConfig;
@@ -57,92 +57,114 @@ const Repositories = () => {
const updateColumnConfig = (newConfig) => {
setColumnConfig(newConfig);
localStorage.setItem('repositories-column-config', JSON.stringify(newConfig));
localStorage.setItem(
"repositories-column-config",
JSON.stringify(newConfig),
);
};
// Fetch repositories
const { data: repositories = [], isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['repositories'],
queryFn: () => repositoryAPI.list().then(res => res.data)
const {
data: repositories = [],
isLoading,
error,
refetch,
isFetching,
} = useQuery({
queryKey: ["repositories"],
queryFn: () => repositoryAPI.list().then((res) => res.data),
});
// Fetch repository statistics
const { data: stats } = useQuery({
queryKey: ['repository-stats'],
queryFn: () => repositoryAPI.getStats().then(res => res.data)
queryKey: ["repository-stats"],
queryFn: () => repositoryAPI.getStats().then((res) => res.data),
});
// Get visible columns in order
const visibleColumns = columnConfig
.filter(col => col.visible)
.filter((col) => col.visible)
.sort((a, b) => a.order - b.order);
// Sorting functions
const handleSort = (field) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDirection('asc');
setSortDirection("asc");
}
};
const getSortIcon = (field) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />
return sortDirection === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />;
return sortDirection === "asc" ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
);
};
// Column management functions
const toggleColumnVisibility = (columnId) => {
const newConfig = columnConfig.map(col =>
col.id === columnId ? { ...col, visible: !col.visible } : col
)
updateColumnConfig(newConfig)
const newConfig = columnConfig.map((col) =>
col.id === columnId ? { ...col, visible: !col.visible } : col,
);
updateColumnConfig(newConfig);
};
const reorderColumns = (fromIndex, toIndex) => {
const newConfig = [...columnConfig]
const [movedColumn] = newConfig.splice(fromIndex, 1)
newConfig.splice(toIndex, 0, movedColumn)
const newConfig = [...columnConfig];
const [movedColumn] = newConfig.splice(fromIndex, 1);
newConfig.splice(toIndex, 0, movedColumn);
// Update order values
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
updateColumnConfig(updatedConfig)
const updatedConfig = newConfig.map((col, index) => ({
...col,
order: index,
}));
updateColumnConfig(updatedConfig);
};
const resetColumns = () => {
const defaultConfig = [
{ id: 'name', label: 'Repository', visible: true, order: 0 },
{ id: 'url', label: 'URL', visible: true, order: 1 },
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 },
{ id: 'security', label: 'Security', visible: true, order: 3 },
{ id: 'status', label: 'Status', visible: true, order: 4 },
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
{ id: 'actions', label: 'Actions', visible: true, order: 6 }
]
updateColumnConfig(defaultConfig)
{ id: "name", label: "Repository", visible: true, order: 0 },
{ id: "url", label: "URL", visible: true, order: 1 },
{ id: "distribution", label: "Distribution", visible: true, order: 2 },
{ id: "security", label: "Security", visible: true, order: 3 },
{ id: "status", label: "Status", visible: true, order: 4 },
{ id: "hostCount", label: "Hosts", visible: true, order: 5 },
{ id: "actions", label: "Actions", visible: true, order: 6 },
];
updateColumnConfig(defaultConfig);
};
// Filter and sort repositories
const filteredAndSortedRepositories = useMemo(() => {
if (!repositories) return []
if (!repositories) return [];
// Filter repositories
const filtered = repositories.filter(repo => {
const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
const filtered = repositories.filter((repo) => {
const matchesSearch =
repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
// Check security based on URL if isSecure property doesn't exist
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
const isSecure =
repo.isSecure !== undefined
? repo.isSecure
: repo.url.startsWith("https://");
const matchesType = filterType === 'all' ||
(filterType === 'secure' && isSecure) ||
(filterType === 'insecure' && !isSecure);
const matchesType =
filterType === "all" ||
(filterType === "secure" && isSecure) ||
(filterType === "insecure" && !isSecure);
const matchesStatus = filterStatus === 'all' ||
(filterStatus === 'active' && repo.is_active === true) ||
(filterStatus === 'inactive' && repo.is_active === false);
const matchesStatus =
filterStatus === "all" ||
(filterStatus === "active" && repo.is_active === true) ||
(filterStatus === "inactive" && repo.is_active === false);
return matchesSearch && matchesType && matchesStatus;
});
@@ -153,26 +175,33 @@ const Repositories = () => {
let bValue = b[sortField];
// Handle special cases
if (sortField === 'security') {
aValue = a.isSecure ? 'Secure' : 'Insecure';
bValue = b.isSecure ? 'Secure' : 'Insecure';
} else if (sortField === 'status') {
aValue = a.is_active ? 'Active' : 'Inactive';
bValue = b.is_active ? 'Active' : 'Inactive';
if (sortField === "security") {
aValue = a.isSecure ? "Secure" : "Insecure";
bValue = b.isSecure ? "Secure" : "Insecure";
} else if (sortField === "status") {
aValue = a.is_active ? "Active" : "Inactive";
bValue = b.is_active ? "Active" : "Inactive";
}
if (typeof aValue === 'string') {
if (typeof aValue === "string") {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
return 0;
});
return sorted;
}, [repositories, searchTerm, filterType, filterStatus, sortField, sortDirection]);
}, [
repositories,
searchTerm,
filterType,
filterStatus,
sortField,
sortDirection,
]);
if (isLoading) {
return (
@@ -200,7 +229,9 @@ const Repositories = () => {
{/* 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>
<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>
@@ -212,8 +243,10 @@ const Repositories = () => {
className="btn-outline flex items-center gap-2"
title="Refresh repositories data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
{isFetching ? "Refreshing..." : "Refresh"}
</button>
</div>
</div>
@@ -224,8 +257,12 @@ const Repositories = () => {
<div className="flex items-center">
<Database className="h-5 w-5 text-primary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Total Repositories</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.totalRepositories || 0}</p>
<p className="text-sm text-secondary-500 dark:text-white">
Total Repositories
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats?.totalRepositories || 0}
</p>
</div>
</div>
</div>
@@ -234,8 +271,12 @@ const Repositories = () => {
<div className="flex items-center">
<Server className="h-5 w-5 text-success-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Active Repositories</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.activeRepositories || 0}</p>
<p className="text-sm text-secondary-500 dark:text-white">
Active Repositories
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats?.activeRepositories || 0}
</p>
</div>
</div>
</div>
@@ -244,8 +285,12 @@ const Repositories = () => {
<div className="flex items-center">
<Shield className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Secure (HTTPS)</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.secureRepositories || 0}</p>
<p className="text-sm text-secondary-500 dark:text-white">
Secure (HTTPS)
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats?.secureRepositories || 0}
</p>
</div>
</div>
</div>
@@ -254,8 +299,12 @@ const Repositories = () => {
<div className="flex items-center">
<ShieldCheck className="h-5 w-5 text-danger-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Security Score</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.securityPercentage || 0}%</p>
<p className="text-sm text-secondary-500 dark:text-white">
Security Score
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats?.securityPercentage || 0}%
</p>
</div>
</div>
</div>
@@ -329,7 +378,9 @@ const Repositories = () => {
<div className="text-center py-8">
<Database className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{repositories?.length === 0 ? 'No repositories found' : 'No repositories match your filters'}
{repositories?.length === 0
? "No repositories found"
: "No repositories match your filters"}
</p>
{repositories?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
@@ -343,7 +394,10 @@ const Repositories = () => {
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
<tr>
{visibleColumns.map((column) => (
<th key={column.id} className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<th
key={column.id}
className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
>
<button
onClick={() => handleSort(column.id)}
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
@@ -357,9 +411,15 @@ const Repositories = () => {
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredAndSortedRepositories.map((repo) => (
<tr key={repo.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
<tr
key={repo.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
>
{visibleColumns.map((column) => (
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
<td
key={column.id}
className="px-4 py-2 whitespace-nowrap text-center"
>
{renderCellContent(column, repo)}
</td>
))}
@@ -389,7 +449,7 @@ const Repositories = () => {
// Render cell content based on column type
function renderCellContent(column, repo) {
switch (column.id) {
case 'name':
case "name":
return (
<div className="flex items-center">
<Database className="h-5 w-5 text-secondary-400 mr-3" />
@@ -399,21 +459,27 @@ const Repositories = () => {
</div>
</div>
</div>
)
case 'url':
);
case "url":
return (
<div className="text-sm text-secondary-900 dark:text-white max-w-xs truncate" title={repo.url}>
<div
className="text-sm text-secondary-900 dark:text-white max-w-xs truncate"
title={repo.url}
>
{repo.url}
</div>
)
case 'distribution':
);
case "distribution":
return (
<div className="text-sm text-secondary-900 dark:text-white">
{repo.distribution}
</div>
)
case 'security':
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
);
case "security": {
const isSecure =
repo.isSecure !== undefined
? repo.isSecure
: repo.url.startsWith("https://");
return (
<div className="flex items-center justify-center">
{isSecure ? (
@@ -428,25 +494,28 @@ const Repositories = () => {
</div>
)}
</div>
)
case 'status':
);
}
case "status":
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repo.is_active
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}>
{repo.is_active ? 'Active' : 'Inactive'}
? "bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300"
: "bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300"
}`}
>
{repo.is_active ? "Active" : "Inactive"}
</span>
)
case 'hostCount':
);
case "hostCount":
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.host_count}</span>
</div>
)
case 'actions':
);
case "actions":
return (
<Link
to={`/repositories/${repo.id}`}
@@ -455,41 +524,52 @@ const Repositories = () => {
View
<Eye className="h-3 w-3" />
</Link>
)
);
default:
return null
return null;
}
}
};
// Column Settings Modal Component
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
const [draggedIndex, setDraggedIndex] = useState(null)
const ColumnSettingsModal = ({
columnConfig,
onClose,
onToggleVisibility,
onReorder,
onReset,
}) => {
const [draggedIndex, setDraggedIndex] = useState(null);
const handleDragStart = (e, index) => {
setDraggedIndex(index)
e.dataTransfer.effectAllowed = 'move'
}
setDraggedIndex(index);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
};
const handleDrop = (e, dropIndex) => {
e.preventDefault()
e.preventDefault();
if (draggedIndex !== null && draggedIndex !== dropIndex) {
onReorder(draggedIndex, dropIndex)
}
setDraggedIndex(null)
onReorder(draggedIndex, dropIndex);
}
setDraggedIndex(null);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Column Settings</h3>
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Column Settings
</h3>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
@@ -514,8 +594,8 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
onClick={() => onToggleVisibility(column.id)}
className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
column.visible
? 'bg-primary-600 border-primary-600'
: 'bg-white dark:bg-secondary-800 border-secondary-300 dark:border-secondary-600'
? "bg-primary-600 border-primary-600"
: "bg-white dark:bg-secondary-800 border-secondary-300 dark:border-secondary-600"
}`}
>
{column.visible && <Check className="h-3 w-3 text-white" />}
@@ -540,7 +620,7 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
</div>
</div>
</div>
)
);
};
export default Repositories;

View File

@@ -1,21 +1,21 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
ArrowLeft,
Calendar,
Database,
Globe,
Lock,
Server,
Shield,
ShieldOff,
AlertTriangle,
Users,
Globe,
Lock,
Unlock,
Database,
Calendar,
Activity
} from 'lucide-react';
import { repositoryAPI } from '../utils/api';
Users,
} from "lucide-react";
import React, { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { repositoryAPI } from "../utils/api";
const RepositoryDetail = () => {
const { repositoryId } = useParams();
@@ -24,29 +24,32 @@ const RepositoryDetail = () => {
const [formData, setFormData] = useState({});
// Fetch repository details
const { data: repository, isLoading, error } = useQuery({
queryKey: ['repository', repositoryId],
queryFn: () => repositoryAPI.getById(repositoryId).then(res => res.data),
enabled: !!repositoryId
const {
data: repository,
isLoading,
error,
} = useQuery({
queryKey: ["repository", repositoryId],
queryFn: () => repositoryAPI.getById(repositoryId).then((res) => res.data),
enabled: !!repositoryId,
});
// Update repository mutation
const updateRepositoryMutation = useMutation({
mutationFn: (data) => repositoryAPI.update(repositoryId, data),
onSuccess: () => {
queryClient.invalidateQueries(['repository', repositoryId]);
queryClient.invalidateQueries(['repositories']);
queryClient.invalidateQueries(["repository", repositoryId]);
queryClient.invalidateQueries(["repositories"]);
setEditMode(false);
}
},
});
const handleEdit = () => {
setFormData({
name: repository.name,
description: repository.description || '',
description: repository.description || "",
is_active: repository.is_active,
priority: repository.priority || ''
priority: repository.priority || "",
});
setEditMode(true);
};
@@ -60,7 +63,6 @@ const RepositoryDetail = () => {
setFormData({});
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -107,7 +109,9 @@ const RepositoryDetail = () => {
</div>
<div className="text-center py-12">
<Database className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">Repository not found</h3>
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
Repository not found
</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
The repository you're looking for doesn't exist.
</p>
@@ -138,12 +142,14 @@ const RepositoryDetail = () => {
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
{repository.name}
</h1>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repository.is_active
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}>
{repository.is_active ? 'Active' : 'Inactive'}
? "bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300"
: "bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300"
}`}
>
{repository.is_active ? "Active" : "Inactive"}
</span>
</div>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
@@ -166,14 +172,13 @@ const RepositoryDetail = () => {
className="btn-primary"
disabled={updateRepositoryMutation.isPending}
>
{updateRepositoryMutation.isPending ? 'Saving...' : 'Save Changes'}
{updateRepositoryMutation.isPending
? "Saving..."
: "Save Changes"}
</button>
</>
) : (
<button
onClick={handleEdit}
className="btn-primary"
>
<button onClick={handleEdit} className="btn-primary">
Edit Repository
</button>
)}
@@ -197,7 +202,9 @@ const RepositoryDetail = () => {
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
/>
</div>
@@ -208,7 +215,9 @@ const RepositoryDetail = () => {
<input
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, priority: e.target.value })
}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
placeholder="Optional priority"
/>
@@ -219,7 +228,9 @@ const RepositoryDetail = () => {
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
rows="3"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
placeholder="Optional description"
@@ -230,10 +241,15 @@ const RepositoryDetail = () => {
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
onChange={(e) =>
setFormData({ ...formData, is_active: e.target.checked })
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label htmlFor="is_active" className="ml-2 block text-sm text-secondary-900 dark:text-white">
<label
htmlFor="is_active"
className="ml-2 block text-sm text-secondary-900 dark:text-white"
>
Repository is active
</label>
</div>
@@ -242,28 +258,46 @@ const RepositoryDetail = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">URL</label>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
URL
</label>
<div className="flex items-center mt-1">
<Globe className="h-4 w-4 text-secondary-400 mr-2" />
<span className="text-secondary-900 dark:text-white">{repository.url}</span>
<span className="text-secondary-900 dark:text-white">
{repository.url}
</span>
</div>
</div>
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Distribution</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.distribution}</p>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Distribution
</label>
<p className="text-secondary-900 dark:text-white mt-1">
{repository.distribution}
</p>
</div>
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Components</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.components}</p>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Components
</label>
<p className="text-secondary-900 dark:text-white mt-1">
{repository.components}
</p>
</div>
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Repository Type</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.repoType}</p>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Repository Type
</label>
<p className="text-secondary-900 dark:text-white mt-1">
{repository.repoType}
</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Security</label>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Security
</label>
<div className="flex items-center mt-1">
{repository.isSecure ? (
<>
@@ -280,18 +314,28 @@ const RepositoryDetail = () => {
</div>
{repository.priority && (
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Priority</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.priority}</p>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Priority
</label>
<p className="text-secondary-900 dark:text-white mt-1">
{repository.priority}
</p>
</div>
)}
{repository.description && (
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Description</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.description}</p>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Description
</label>
<p className="text-secondary-900 dark:text-white mt-1">
{repository.description}
</p>
</div>
)}
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Created</label>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Created
</label>
<div className="flex items-center mt-1">
<Calendar className="h-4 w-4 text-secondary-400 mr-2" />
<span className="text-secondary-900 dark:text-white">
@@ -310,13 +354,17 @@ 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.host_repositories?.length || 0})
Hosts Using This Repository (
{repository.host_repositories?.length || 0})
</h2>
</div>
{!repository.host_repositories || repository.host_repositories.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>
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
No hosts using this repository
</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
This repository hasn't been reported by any hosts yet.
</p>
@@ -324,16 +372,21 @@ const RepositoryDetail = () => {
) : (
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
{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
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.hosts.status === 'active'
? 'bg-green-500'
: hostRepo.hosts.status === 'pending'
? 'bg-yellow-500'
: 'bg-red-500'
}`} />
<div
className={`w-3 h-3 rounded-full ${
hostRepo.hosts.status === "active"
? "bg-green-500"
: hostRepo.hosts.status === "pending"
? "bg-yellow-500"
: "bg-red-500"
}`}
/>
<div>
<Link
to={`/hosts/${hostRepo.hosts.id}`}
@@ -343,14 +396,24 @@ const RepositoryDetail = () => {
</Link>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
<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>
<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>
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-xs text-secondary-500 dark:text-secondary-400">Last Checked</div>
<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.last_checked).toLocaleDateString()}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,83 +1,103 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle, Key } from 'lucide-react'
import { adminUsersAPI, permissionsAPI } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Calendar,
CheckCircle,
Edit,
Key,
Mail,
Plus,
Shield,
Trash2,
User,
XCircle,
} from "lucide-react";
import React, { useState } from "react";
import { useAuth } from "../contexts/AuthContext";
import { adminUsersAPI, permissionsAPI } from "../utils/api";
const Users = () => {
const [showAddModal, setShowAddModal] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const [resetPasswordUser, setResetPasswordUser] = useState(null)
const queryClient = useQueryClient()
const { user: currentUser } = useAuth()
const [showAddModal, setShowAddModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [resetPasswordUser, setResetPasswordUser] = useState(null);
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
// Fetch users
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => adminUsersAPI.list().then(res => res.data)
})
const {
data: users,
isLoading,
error,
} = useQuery({
queryKey: ["users"],
queryFn: () => adminUsersAPI.list().then((res) => res.data),
});
// Fetch available roles
const { data: roles } = useQuery({
queryKey: ['rolePermissions'],
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
})
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Delete user mutation
const deleteUserMutation = useMutation({
mutationFn: adminUsersAPI.delete,
onSuccess: () => {
queryClient.invalidateQueries(['users'])
}
})
queryClient.invalidateQueries(["users"]);
},
});
// Update user mutation
const updateUserMutation = useMutation({
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(['users'])
setEditingUser(null)
}
})
queryClient.invalidateQueries(["users"]);
setEditingUser(null);
},
});
// Reset password mutation
const resetPasswordMutation = useMutation({
mutationFn: ({ userId, newPassword }) => adminUsersAPI.resetPassword(userId, newPassword),
mutationFn: ({ userId, newPassword }) =>
adminUsersAPI.resetPassword(userId, newPassword),
onSuccess: () => {
queryClient.invalidateQueries(['users'])
setResetPasswordUser(null)
}
})
queryClient.invalidateQueries(["users"]);
setResetPasswordUser(null);
},
});
const handleDeleteUser = async (userId, username) => {
if (window.confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
if (
window.confirm(
`Are you sure you want to delete user "${username}"? This action cannot be undone.`,
)
) {
try {
await deleteUserMutation.mutateAsync(userId)
await deleteUserMutation.mutateAsync(userId);
} catch (error) {
console.error('Failed to delete user:', error)
}
console.error("Failed to delete user:", error);
}
}
};
const handleUserCreated = () => {
queryClient.invalidateQueries(['users'])
setShowAddModal(false)
}
queryClient.invalidateQueries(["users"]);
setShowAddModal(false);
};
const handleEditUser = (user) => {
setEditingUser(user)
}
setEditingUser(user);
};
const handleResetPassword = (user) => {
setResetPasswordUser(user)
}
setResetPasswordUser(user);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
);
}
if (error) {
@@ -86,12 +106,14 @@ const Users = () => {
<div className="flex">
<XCircle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">Error loading users</h3>
<h3 className="text-sm font-medium text-danger-800">
Error loading users
</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
)
);
}
return (
@@ -122,23 +144,28 @@ const Users = () => {
</div>
<div className="ml-4">
<div className="flex items-center">
<p className="text-sm font-medium text-secondary-900 dark:text-white">{user.username}</p>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{user.username}
</p>
{user.id === currentUser?.id && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
You
</span>
)}
<span className={`ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.role === 'admin'
? 'bg-primary-100 text-primary-800'
: user.role === 'host_manager'
? 'bg-green-100 text-green-800'
: user.role === 'readonly'
? 'bg-yellow-100 text-yellow-800'
: 'bg-secondary-100 text-secondary-800'
}`}>
<span
className={`ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.role === "admin"
? "bg-primary-100 text-primary-800"
: user.role === "host_manager"
? "bg-green-100 text-green-800"
: user.role === "readonly"
? "bg-yellow-100 text-yellow-800"
: "bg-secondary-100 text-secondary-800"
}`}
>
<Shield className="h-3 w-3 mr-1" />
{user.role.charAt(0).toUpperCase() + user.role.slice(1).replace('_', ' ')}
{user.role.charAt(0).toUpperCase() +
user.role.slice(1).replace("_", " ")}
</span>
{user.is_active ? (
<CheckCircle className="ml-2 h-4 w-4 text-green-500" />
@@ -152,11 +179,13 @@ const Users = () => {
</div>
<div className="flex items-center mt-1 text-sm text-secondary-500 dark:text-secondary-300">
<Calendar className="h-4 w-4 mr-1" />
Created: {new Date(user.created_at).toLocaleDateString()}
Created:{" "}
{new Date(user.created_at).toLocaleDateString()}
{user.last_login && (
<>
<span className="mx-2"></span>
Last login: {new Date(user.last_login).toLocaleDateString()}
Last login:{" "}
{new Date(user.last_login).toLocaleDateString()}
</>
)}
</div>
@@ -188,13 +217,16 @@ const Users = () => {
title={
user.id === currentUser?.id
? "Cannot delete your own account"
: user.role === 'admin' && users.filter(u => u.role === 'admin').length === 1
: user.role === "admin" &&
users.filter((u) => u.role === "admin").length ===
1
? "Cannot delete the last admin user"
: "Delete user"
}
disabled={
user.id === currentUser?.id ||
(user.role === 'admin' && users.filter(u => u.role === 'admin').length === 1)
(user.role === "admin" &&
users.filter((u) => u.role === "admin").length === 1)
}
>
<Trash2 className="h-4 w-4" />
@@ -207,7 +239,9 @@ const Users = () => {
<li>
<div className="px-4 py-8 text-center">
<User className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">No users found</p>
<p className="text-secondary-500 dark:text-secondary-300">
No users found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Click "Add User" to create the first user
</p>
@@ -247,55 +281,61 @@ const Users = () => {
/>
)}
</div>
)
}
);
};
// Add User Modal Component
const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
first_name: '',
last_name: '',
role: 'user'
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
username: "",
email: "",
password: "",
first_name: "",
last_name: "",
role: "user",
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
e.preventDefault();
setIsLoading(true);
setError("");
try {
// Only send role if roles are available from API
const payload = { username: formData.username, email: formData.email, password: formData.password }
const payload = {
username: formData.username,
email: formData.email,
password: formData.password,
};
if (roles && Array.isArray(roles) && roles.length > 0) {
payload.role = formData.role
payload.role = formData.role;
}
const response = await adminUsersAPI.create(payload)
onUserCreated()
const response = await adminUsersAPI.create(payload);
onUserCreated();
} catch (err) {
setError(err.response?.data?.error || 'Failed to create user')
setError(err.response?.data?.error || "Failed to create user");
} finally {
setIsLoading(false)
}
setIsLoading(false);
}
};
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
[e.target.name]: e.target.value,
});
};
if (!isOpen) return null
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Add New User</h3>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Add New User
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
@@ -366,7 +406,9 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
onChange={handleInputChange}
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"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">Minimum 6 characters</p>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Minimum 6 characters
</p>
</div>
<div>
@@ -382,7 +424,8 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
{roles && Array.isArray(roles) && roles.length > 0 ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
{role.role.charAt(0).toUpperCase() +
role.role.slice(1).replace("_", " ")}
</option>
))
) : (
@@ -396,7 +439,9 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
@@ -413,57 +458,59 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create User'}
{isLoading ? "Creating..." : "Create User"}
</button>
</div>
</form>
</div>
</div>
)
}
);
};
// Edit User Modal Component
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
const [formData, setFormData] = useState({
username: user?.username || '',
email: user?.email || '',
first_name: user?.first_name || '',
last_name: user?.last_name || '',
role: user?.role || 'user',
is_active: user?.is_active ?? true
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
username: user?.username || "",
email: user?.email || "",
first_name: user?.first_name || "",
last_name: user?.last_name || "",
role: user?.role || "user",
is_active: user?.is_active ?? true,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
e.preventDefault();
setIsLoading(true);
setError("");
try {
await adminUsersAPI.update(user.id, formData)
onUserUpdated()
await adminUsersAPI.update(user.id, formData);
onUserUpdated();
} catch (err) {
setError(err.response?.data?.error || 'Failed to update user')
setError(err.response?.data?.error || "Failed to update user");
} finally {
setIsLoading(false)
}
setIsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value
})
}
[name]: type === "checkbox" ? checked : value,
});
};
if (!isOpen || !user) return null
if (!isOpen || !user) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Edit User</h3>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Edit User
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
@@ -534,7 +581,8 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
{roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
{role.role.charAt(0).toUpperCase() +
role.role.slice(1).replace("_", " ")}
</option>
))
) : (
@@ -561,7 +609,9 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
@@ -578,54 +628,60 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? 'Updating...' : 'Update User'}
{isLoading ? "Updating..." : "Update User"}
</button>
</div>
</form>
</div>
</div>
)
}
);
};
// Reset Password Modal Component
const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading }) => {
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const ResetPasswordModal = ({
user,
isOpen,
onClose,
onPasswordReset,
isLoading,
}) => {
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
e.preventDefault();
setError("");
// Validate passwords
if (newPassword.length < 6) {
setError('Password must be at least 6 characters long')
return
setError("Password must be at least 6 characters long");
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match')
return
setError("Passwords do not match");
return;
}
try {
await onPasswordReset({ userId: user.id, newPassword })
await onPasswordReset({ userId: user.id, newPassword });
// Reset form on success
setNewPassword('')
setConfirmPassword('')
setNewPassword("");
setConfirmPassword("");
} catch (err) {
setError(err.response?.data?.error || 'Failed to reset password')
}
setError(err.response?.data?.error || "Failed to reset password");
}
};
const handleClose = () => {
setNewPassword('')
setConfirmPassword('')
setError('')
onClose()
}
setNewPassword("");
setConfirmPassword("");
setError("");
onClose();
};
if (!isOpen) return null
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
@@ -674,7 +730,10 @@ const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading
Password Reset Warning
</h3>
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>This will immediately change the user's password. The user will need to use the new password to login.</p>
<p>
This will immediately change the user's password. The user
will need to use the new password to login.
</p>
</div>
</div>
</div>
@@ -682,7 +741,9 @@ const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
@@ -699,14 +760,16 @@ const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center"
>
{isLoading && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>}
{isLoading ? 'Resetting...' : 'Reset Password'}
{isLoading && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
)}
{isLoading ? "Resetting..." : "Reset Password"}
</button>
</div>
</form>
</div>
</div>
)
}
);
};
export default Users
export default Users;

View File

@@ -1,30 +1,30 @@
import axios from 'axios'
import axios from "axios";
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
// Create axios instance with default config
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
})
});
// Request interceptor
api.interceptors.request.use(
(config) => {
// Add auth token if available
const token = localStorage.getItem('token')
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = `Bearer ${token}`;
}
return config
return config;
},
(error) => {
return Promise.reject(error)
}
)
return Promise.reject(error);
},
);
// Response interceptor
api.interceptors.response.use(
@@ -32,200 +32,235 @@ api.interceptors.response.use(
(error) => {
if (error.response?.status === 401) {
// Don't redirect if we're on the login page or if it's a TFA verification error
const currentPath = window.location.pathname
const isTfaError = error.config?.url?.includes('/verify-tfa')
const currentPath = window.location.pathname;
const isTfaError = error.config?.url?.includes("/verify-tfa");
if (currentPath !== '/login' && !isTfaError) {
if (currentPath !== "/login" && !isTfaError) {
// Handle unauthorized
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('permissions')
window.location.href = '/login'
localStorage.removeItem("token");
localStorage.removeItem("user");
localStorage.removeItem("permissions");
window.location.href = "/login";
}
}
return Promise.reject(error)
}
)
return Promise.reject(error);
},
);
// Dashboard API
export const dashboardAPI = {
getStats: () => api.get('/dashboard/stats'),
getHosts: () => api.get('/dashboard/hosts'),
getPackages: () => api.get('/dashboard/packages'),
getStats: () => api.get("/dashboard/stats"),
getHosts: () => api.get("/dashboard/hosts"),
getPackages: () => api.get("/dashboard/packages"),
getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
getRecentUsers: () => api.get('/dashboard/recent-users'),
getRecentCollection: () => api.get('/dashboard/recent-collection')
}
getRecentUsers: () => api.get("/dashboard/recent-users"),
getRecentCollection: () => api.get("/dashboard/recent-collection"),
};
// Admin Hosts API (for management interface)
export const adminHostsAPI = {
create: (data) => api.post('/hosts/create', data),
list: () => api.get('/hosts/admin/list'),
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 }),
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendly_name: friendlyName })
}
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 }),
toggleAutoUpdate: (hostId, autoUpdate) =>
api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
updateFriendlyName: (hostId, friendlyName) =>
api.patch(`/hosts/${hostId}/friendly-name`, {
friendly_name: friendlyName,
}),
};
// Host Groups API
export const hostGroupsAPI = {
list: () => api.get('/host-groups'),
list: () => api.get("/host-groups"),
get: (id) => api.get(`/host-groups/${id}`),
create: (data) => api.post('/host-groups', data),
create: (data) => api.post("/host-groups", data),
update: (id, data) => api.put(`/host-groups/${id}`, data),
delete: (id) => api.delete(`/host-groups/${id}`),
getHosts: (id) => api.get(`/host-groups/${id}/hosts`),
}
};
// Admin Users API (for user management)
export const adminUsersAPI = {
list: () => api.get('/auth/admin/users'),
create: (userData) => api.post('/auth/admin/users', userData),
update: (userId, userData) => api.put(`/auth/admin/users/${userId}`, userData),
list: () => api.get("/auth/admin/users"),
create: (userData) => api.post("/auth/admin/users", userData),
update: (userId, userData) =>
api.put(`/auth/admin/users/${userId}`, userData),
delete: (userId) => api.delete(`/auth/admin/users/${userId}`),
resetPassword: (userId, newPassword) => api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword })
}
resetPassword: (userId, newPassword) =>
api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword }),
};
// Permissions API (for role management)
export const permissionsAPI = {
getRoles: () => api.get('/permissions/roles'),
getRoles: () => api.get("/permissions/roles"),
getRole: (role) => api.get(`/permissions/roles/${role}`),
updateRole: (role, permissions) => api.put(`/permissions/roles/${role}`, permissions),
updateRole: (role, permissions) =>
api.put(`/permissions/roles/${role}`, permissions),
deleteRole: (role) => api.delete(`/permissions/roles/${role}`),
getUserPermissions: () => api.get('/permissions/user-permissions')
}
getUserPermissions: () => api.get("/permissions/user-permissions"),
};
// Settings API
export const settingsAPI = {
get: () => api.get('/settings'),
update: (settings) => api.put('/settings', settings),
getServerUrl: () => api.get('/settings/server-url')
}
get: () => api.get("/settings"),
update: (settings) => api.put("/settings", settings),
getServerUrl: () => api.get("/settings/server-url"),
};
// Agent Version API
export const agentVersionAPI = {
list: () => api.get('/hosts/agent/versions'),
create: (data) => api.post('/hosts/agent/versions', data),
list: () => api.get("/hosts/agent/versions"),
create: (data) => api.post("/hosts/agent/versions", data),
update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data),
delete: (id) => api.delete(`/hosts/agent/versions/${id}`),
setCurrent: (id) => api.patch(`/hosts/agent/versions/${id}/current`),
setDefault: (id) => api.patch(`/hosts/agent/versions/${id}/default`),
download: (version) => api.get(`/hosts/agent/download${version ? `?version=${version}` : ''}`, { responseType: 'blob' })
}
download: (version) =>
api.get(`/hosts/agent/download${version ? `?version=${version}` : ""}`, {
responseType: "blob",
}),
};
// Repository API
export const repositoryAPI = {
list: () => api.get('/repositories'),
list: () => api.get("/repositories"),
getById: (repositoryId) => api.get(`/repositories/${repositoryId}`),
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
update: (repositoryId, data) => api.put(`/repositories/${repositoryId}`, data),
update: (repositoryId, data) =>
api.put(`/repositories/${repositoryId}`, data),
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, { isEnabled }),
getStats: () => api.get('/repositories/stats/summary'),
cleanupOrphaned: () => api.delete('/repositories/cleanup/orphaned')
}
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, {
isEnabled,
}),
getStats: () => api.get("/repositories/stats/summary"),
cleanupOrphaned: () => api.delete("/repositories/cleanup/orphaned"),
};
// Dashboard Preferences API
export const dashboardPreferencesAPI = {
get: () => api.get('/dashboard-preferences'),
update: (preferences) => api.put('/dashboard-preferences', { preferences }),
getDefaults: () => api.get('/dashboard-preferences/defaults')
}
get: () => api.get("/dashboard-preferences"),
update: (preferences) => api.put("/dashboard-preferences", { preferences }),
getDefaults: () => api.get("/dashboard-preferences/defaults"),
};
// Hosts API (for agent communication - kept for compatibility)
export const hostsAPI = {
// Legacy register endpoint (now deprecated)
register: (data) => api.post('/hosts/register', data),
register: (data) => api.post("/hosts/register", data),
// Updated to use API credentials
update: (apiId, apiKey, data) => api.post('/hosts/update', data, {
update: (apiId, apiKey, data) =>
api.post("/hosts/update", data, {
headers: {
'X-API-ID': apiId,
'X-API-KEY': apiKey
}
"X-API-ID": apiId,
"X-API-KEY": apiKey,
},
}),
getInfo: (apiId, apiKey) => api.get('/hosts/info', {
getInfo: (apiId, apiKey) =>
api.get("/hosts/info", {
headers: {
'X-API-ID': apiId,
'X-API-KEY': apiKey
}
"X-API-ID": apiId,
"X-API-KEY": apiKey,
},
}),
ping: (apiId, apiKey) => api.post('/hosts/ping', {}, {
ping: (apiId, apiKey) =>
api.post(
"/hosts/ping",
{},
{
headers: {
'X-API-ID': apiId,
'X-API-KEY': apiKey
}
}),
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate })
}
"X-API-ID": apiId,
"X-API-KEY": apiKey,
},
},
),
toggleAutoUpdate: (id, autoUpdate) =>
api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate }),
};
// Packages API
export const packagesAPI = {
getAll: (params = {}) => api.get('/packages', { params }),
getAll: (params = {}) => api.get("/packages", { params }),
getById: (packageId) => api.get(`/packages/${packageId}`),
getCategories: () => api.get('/packages/categories/list'),
getHosts: (packageId, params = {}) => api.get(`/packages/${packageId}/hosts`, { params }),
getCategories: () => api.get("/packages/categories/list"),
getHosts: (packageId, params = {}) =>
api.get(`/packages/${packageId}/hosts`, { params }),
update: (packageId, data) => api.put(`/packages/${packageId}`, data),
search: (query, params = {}) => api.get(`/packages/search/${query}`, { params }),
}
search: (query, params = {}) =>
api.get(`/packages/search/${query}`, { params }),
};
// Utility functions
export const formatError = (error) => {
if (error.response?.data?.message) {
return error.response.data.message
return error.response.data.message;
}
if (error.response?.data?.error) {
return error.response.data.error
return error.response.data.error;
}
if (error.message) {
return error.message
}
return 'An unexpected error occurred'
return error.message;
}
return "An unexpected error occurred";
};
export const formatDate = (date) => {
return new Date(date).toLocaleString()
}
return new Date(date).toLocaleString();
};
// Version API
export const versionAPI = {
getCurrent: () => api.get('/version/current'),
checkUpdates: () => api.get('/version/check-updates'),
testSshKey: (data) => api.post('/version/test-ssh-key', data),
}
getCurrent: () => api.get("/version/current"),
checkUpdates: () => api.get("/version/check-updates"),
testSshKey: (data) => api.post("/version/test-ssh-key", data),
};
// Auth API
export const authAPI = {
login: (username, password) => api.post('/auth/login', { username, password }),
verifyTfa: (username, token) => api.post('/auth/verify-tfa', { username, token }),
signup: (username, email, password, firstName, lastName) => api.post('/auth/signup', { username, email, password, firstName, lastName }),
}
login: (username, password) =>
api.post("/auth/login", { username, password }),
verifyTfa: (username, token) =>
api.post("/auth/verify-tfa", { username, token }),
signup: (username, email, password, firstName, lastName) =>
api.post("/auth/signup", {
username,
email,
password,
firstName,
lastName,
}),
};
// TFA API
export const tfaAPI = {
setup: () => api.get('/tfa/setup'),
verifySetup: (data) => api.post('/tfa/verify-setup', data),
disable: (data) => api.post('/tfa/disable', data),
status: () => api.get('/tfa/status'),
regenerateBackupCodes: () => api.post('/tfa/regenerate-backup-codes'),
verify: (data) => api.post('/tfa/verify', data),
}
setup: () => api.get("/tfa/setup"),
verifySetup: (data) => api.post("/tfa/verify-setup", data),
disable: (data) => api.post("/tfa/disable", data),
status: () => api.get("/tfa/status"),
regenerateBackupCodes: () => api.post("/tfa/regenerate-backup-codes"),
verify: (data) => api.post("/tfa/verify", data),
};
export const formatRelativeTime = (date) => {
const now = new Date()
const diff = now - new Date(date)
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const now = new Date();
const diff = now - new Date(date);
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
return `${seconds} second${seconds > 1 ? 's' : ''} ago`
}
if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
};
export default api
export default api;

View File

@@ -1,32 +1,25 @@
import {
Cpu,
Globe,
HardDrive,
Monitor,
Server,
HardDrive,
Cpu,
Zap,
Shield,
Globe,
Terminal
} from 'lucide-react';
Terminal,
Zap,
} from "lucide-react";
import { DiDebian, DiLinux, DiUbuntu, DiWindows } from "react-icons/di";
// Import OS icons from react-icons
import {
SiUbuntu,
SiDebian,
SiCentos,
SiFedora,
SiArchlinux,
SiAlpinelinux,
SiArchlinux,
SiCentos,
SiDebian,
SiFedora,
SiLinux,
SiMacos
} from 'react-icons/si';
import {
DiUbuntu,
DiDebian,
DiLinux,
DiWindows
} from 'react-icons/di';
SiMacos,
SiUbuntu,
} from "react-icons/si";
/**
* OS Icon mapping utility
@@ -38,25 +31,26 @@ export const getOSIcon = (osType) => {
const os = osType.toLowerCase();
// Linux distributions with authentic react-icons
if (os.includes('ubuntu')) return SiUbuntu;
if (os.includes('debian')) return SiDebian;
if (os.includes('centos') || os.includes('rhel') || os.includes('red hat')) return SiCentos;
if (os.includes('fedora')) return SiFedora;
if (os.includes('arch')) return SiArchlinux;
if (os.includes('alpine')) return SiAlpinelinux;
if (os.includes('suse') || os.includes('opensuse')) return SiLinux; // SUSE uses generic Linux icon
if (os.includes("ubuntu")) return SiUbuntu;
if (os.includes("debian")) return SiDebian;
if (os.includes("centos") || os.includes("rhel") || os.includes("red hat"))
return SiCentos;
if (os.includes("fedora")) return SiFedora;
if (os.includes("arch")) return SiArchlinux;
if (os.includes("alpine")) return SiAlpinelinux;
if (os.includes("suse") || os.includes("opensuse")) return SiLinux; // SUSE uses generic Linux icon
// Generic Linux
if (os.includes('linux')) return SiLinux;
if (os.includes("linux")) return SiLinux;
// Windows
if (os.includes('windows')) return DiWindows;
if (os.includes("windows")) return DiWindows;
// macOS
if (os.includes('mac') || os.includes('darwin')) return SiMacos;
if (os.includes("mac") || os.includes("darwin")) return SiMacos;
// FreeBSD
if (os.includes('freebsd')) return Server;
if (os.includes("freebsd")) return Server;
// Default fallback
return Monitor;
@@ -67,11 +61,11 @@ export const getOSIcon = (osType) => {
* Maps operating system types to appropriate colors (react-icons have built-in brand colors)
*/
export const getOSColor = (osType) => {
if (!osType) return 'text-gray-500';
if (!osType) return "text-gray-500";
// react-icons already have the proper brand colors built-in
// This function is kept for compatibility but returns neutral colors
return 'text-gray-600';
return "text-gray-600";
};
/**
@@ -79,32 +73,33 @@ export const getOSColor = (osType) => {
* Provides clean, formatted OS names for display
*/
export const getOSDisplayName = (osType) => {
if (!osType) return 'Unknown';
if (!osType) return "Unknown";
const os = osType.toLowerCase();
// Linux distributions
if (os.includes('ubuntu')) return 'Ubuntu';
if (os.includes('debian')) return 'Debian';
if (os.includes('centos')) return 'CentOS';
if (os.includes('rhel') || os.includes('red hat')) return 'Red Hat Enterprise Linux';
if (os.includes('fedora')) return 'Fedora';
if (os.includes('arch')) return 'Arch Linux';
if (os.includes('suse')) return 'SUSE Linux';
if (os.includes('opensuse')) return 'openSUSE';
if (os.includes('alpine')) return 'Alpine Linux';
if (os.includes("ubuntu")) return "Ubuntu";
if (os.includes("debian")) return "Debian";
if (os.includes("centos")) return "CentOS";
if (os.includes("rhel") || os.includes("red hat"))
return "Red Hat Enterprise Linux";
if (os.includes("fedora")) return "Fedora";
if (os.includes("arch")) return "Arch Linux";
if (os.includes("suse")) return "SUSE Linux";
if (os.includes("opensuse")) return "openSUSE";
if (os.includes("alpine")) return "Alpine Linux";
// Generic Linux
if (os.includes('linux')) return 'Linux';
if (os.includes("linux")) return "Linux";
// Windows
if (os.includes('windows')) return 'Windows';
if (os.includes("windows")) return "Windows";
// macOS
if (os.includes('mac') || os.includes('darwin')) return 'macOS';
if (os.includes("mac") || os.includes("darwin")) return "macOS";
// FreeBSD
if (os.includes('freebsd')) return 'FreeBSD';
if (os.includes("freebsd")) return "FreeBSD";
// Return original if no match
return osType;

View File

@@ -1,85 +1,85 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
secondary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
50: "#f8fafc",
100: "#f1f5f9",
200: "#e2e8f0",
300: "#cbd5e1",
400: "#94a3b8",
500: "#64748b",
600: "#475569",
700: "#334155",
800: "#1e293b",
900: "#0f172a",
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
50: "#f0fdf4",
100: "#dcfce7",
200: "#bbf7d0",
300: "#86efac",
400: "#4ade80",
500: "#22c55e",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
50: "#fffbeb",
100: "#fef3c7",
200: "#fde68a",
300: "#fcd34d",
400: "#fbbf24",
500: "#f59e0b",
600: "#d97706",
700: "#b45309",
800: "#92400e",
900: "#78350f",
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
50: "#fef2f2",
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
400: "#f87171",
500: "#ef4444",
600: "#dc2626",
700: "#b91c1c",
800: "#991b1b",
900: "#7f1d1d",
},
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
sans: ["Inter", "ui-sans-serif", "system-ui"],
mono: ["JetBrains Mono", "ui-monospace", "monospace"],
},
boxShadow: {
'card': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
'card-hover': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'card-dark': '0 1px 3px 0 rgba(255, 255, 255, 0.1), 0 1px 2px 0 rgba(255, 255, 255, 0.06)',
'card-hover-dark': '0 4px 6px -1px rgba(255, 255, 255, 0.15), 0 2px 4px -1px rgba(255, 255, 255, 0.1)',
card: "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)",
"card-hover":
"0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
"card-dark":
"0 1px 3px 0 rgba(255, 255, 255, 0.1), 0 1px 2px 0 rgba(255, 255, 255, 0.06)",
"card-hover-dark":
"0 4px 6px -1px rgba(255, 255, 255, 0.15), 0 2px 4px -1px rgba(255, 255, 255, 0.1)",
},
},
},
plugins: [],
}
};

View File

@@ -1,35 +1,47 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: "0.0.0.0", // Listen on all interfaces
strictPort: true, // Exit if port is already in use
allowedHosts: ['localhost'],
allowedHosts: true, // Allow all hosts in development
proxy: {
'/api': {
target: 'http://localhost:3001',
"/api": {
target: `http://${process.env.BACKEND_HOST}:${process.env.BACKEND_PORT}`,
changeOrigin: true,
secure: false,
configure: process.env.VITE_ENABLE_LOGGING === 'true' ? (proxy, options) => {
proxy.on('error', (err, req, res) => {
console.log('proxy error', err);
configure:
process.env.VITE_ENABLE_LOGGING === "true"
? (proxy, options) => {
proxy.on("error", (err, req, res) => {
console.log("proxy error", err);
});
proxy.on('proxyReq', (proxyReq, req, res) => {
console.log('Sending Request to the Target:', req.method, req.url);
proxy.on("proxyReq", (proxyReq, req, res) => {
console.log(
"Sending Request to the Target:",
req.method,
req.url,
);
});
proxy.on('proxyRes', (proxyRes, req, res) => {
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
proxy.on("proxyRes", (proxyRes, req, res) => {
console.log(
"Received Response from the Target:",
proxyRes.statusCode,
req.url,
);
});
} : undefined,
}
: undefined,
},
},
},
build: {
outDir: 'dist',
sourcemap: process.env.NODE_ENV !== 'production',
target: 'es2018',
outDir: "dist",
sourcemap: process.env.NODE_ENV !== "production",
target: "es2018",
},
})
});