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: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };

View File

@@ -1,45 +1,50 @@
import express from 'express'; import cors from "cors";
import path from 'path'; import express from "express";
import cors from 'cors'; import { createProxyMiddleware } from "http-proxy-middleware";
import { fileURLToPath } from 'url'; import path from "path";
import { createProxyMiddleware } from 'http-proxy-middleware'; import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; 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 // Enable CORS for API calls
app.use(cors({ app.use(
origin: process.env.CORS_ORIGIN || '*', cors({
credentials: true origin: process.env.CORS_ORIGIN || "*",
})); credentials: true,
}),
);
// Proxy API requests to backend // Proxy API requests to backend
app.use('/api', createProxyMiddleware({ app.use(
"/api",
createProxyMiddleware({
target: BACKEND_URL, target: BACKEND_URL,
changeOrigin: true, changeOrigin: true,
logLevel: 'info', logLevel: "info",
onError: (err, req, res) => { onError: (err, req, res) => {
console.error('Proxy error:', err.message); console.error("Proxy error:", err.message);
res.status(500).json({ error: 'Backend service unavailable' }); res.status(500).json({ error: "Backend service unavailable" });
}, },
onProxyReq: (proxyReq, req, res) => { onProxyReq: (proxyReq, req, res) => {
console.log(`Proxying ${req.method} ${req.path} to ${BACKEND_URL}`); console.log(`Proxying ${req.method} ${req.path} to ${BACKEND_URL}`);
} },
})); }),
);
// Serve static files from dist directory // 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 // Handle SPA routing - serve index.html for all routes
app.get('*', (req, res) => { app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html')); res.sendFile(path.join(__dirname, "dist", "index.html"));
}); });
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Frontend server running on port ${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 React from "react";
import { Routes, Route } from 'react-router-dom' import { Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from './contexts/AuthContext' import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
import { ThemeProvider } from './contexts/ThemeContext' import Layout from "./components/Layout";
import { UpdateNotificationProvider } from './contexts/UpdateNotificationContext' import ProtectedRoute from "./components/ProtectedRoute";
import ProtectedRoute from './components/ProtectedRoute' import { AuthProvider, useAuth } from "./contexts/AuthContext";
import Layout from './components/Layout' import { ThemeProvider } from "./contexts/ThemeContext";
import Login from './pages/Login' import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
import Dashboard from './pages/Dashboard' import Dashboard from "./pages/Dashboard";
import Hosts from './pages/Hosts' import HostDetail from "./pages/HostDetail";
import Packages from './pages/Packages' import Hosts from "./pages/Hosts";
import Repositories from './pages/Repositories' import Login from "./pages/Login";
import RepositoryDetail from './pages/RepositoryDetail' import Options from "./pages/Options";
import Users from './pages/Users' import PackageDetail from "./pages/PackageDetail";
import Permissions from './pages/Permissions' import Packages from "./pages/Packages";
import Settings from './pages/Settings' import Permissions from "./pages/Permissions";
import Options from './pages/Options' import Profile from "./pages/Profile";
import Profile from './pages/Profile' import Repositories from "./pages/Repositories";
import HostDetail from './pages/HostDetail' import RepositoryDetail from "./pages/RepositoryDetail";
import PackageDetail from './pages/PackageDetail' import Settings from "./pages/Settings";
import FirstTimeAdminSetup from './components/FirstTimeAdminSetup' import Users from "./pages/Users";
function AppRoutes() { function AppRoutes() {
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth() const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth();
const isAuth = isAuthenticated() // Call the function to get boolean value const isAuth = isAuthenticated(); // Call the function to get boolean value
// Show loading while checking if setup is needed // Show loading while checking if setup is needed
if (checkingSetup) { 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="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="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div> <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>
</div> </div>
) );
} }
// Show first-time setup if no admin users exist // Show first-time setup if no admin users exist
if (needsFirstTimeSetup && !isAuth) { if (needsFirstTimeSetup && !isAuth) {
return <FirstTimeAdminSetup /> return <FirstTimeAdminSetup />;
} }
return ( return (
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/" element={ <Route
path="/"
element={
<ProtectedRoute requirePermission="can_view_dashboard"> <ProtectedRoute requirePermission="can_view_dashboard">
<Layout> <Layout>
<Dashboard /> <Dashboard />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/hosts" element={ />
<Route
path="/hosts"
element={
<ProtectedRoute requirePermission="can_view_hosts"> <ProtectedRoute requirePermission="can_view_hosts">
<Layout> <Layout>
<Hosts /> <Hosts />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/hosts/:hostId" element={ />
<Route
path="/hosts/:hostId"
element={
<ProtectedRoute requirePermission="can_view_hosts"> <ProtectedRoute requirePermission="can_view_hosts">
<Layout> <Layout>
<HostDetail /> <HostDetail />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/packages" element={ />
<Route
path="/packages"
element={
<ProtectedRoute requirePermission="can_view_packages"> <ProtectedRoute requirePermission="can_view_packages">
<Layout> <Layout>
<Packages /> <Packages />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/repositories" element={ />
<Route
path="/repositories"
element={
<ProtectedRoute requirePermission="can_view_hosts"> <ProtectedRoute requirePermission="can_view_hosts">
<Layout> <Layout>
<Repositories /> <Repositories />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/repositories/:repositoryId" element={ />
<Route
path="/repositories/:repositoryId"
element={
<ProtectedRoute requirePermission="can_view_hosts"> <ProtectedRoute requirePermission="can_view_hosts">
<Layout> <Layout>
<RepositoryDetail /> <RepositoryDetail />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/users" element={ />
<Route
path="/users"
element={
<ProtectedRoute requirePermission="can_view_users"> <ProtectedRoute requirePermission="can_view_users">
<Layout> <Layout>
<Users /> <Users />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/permissions" element={ />
<Route
path="/permissions"
element={
<ProtectedRoute requirePermission="can_manage_settings"> <ProtectedRoute requirePermission="can_manage_settings">
<Layout> <Layout>
<Permissions /> <Permissions />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/settings" element={ />
<Route
path="/settings"
element={
<ProtectedRoute requirePermission="can_manage_settings"> <ProtectedRoute requirePermission="can_manage_settings">
<Layout> <Layout>
<Settings /> <Settings />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/options" element={ />
<Route
path="/options"
element={
<ProtectedRoute requirePermission="can_manage_hosts"> <ProtectedRoute requirePermission="can_manage_hosts">
<Layout> <Layout>
<Options /> <Options />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/profile" element={ />
<Route
path="/profile"
element={
<ProtectedRoute> <ProtectedRoute>
<Layout> <Layout>
<Profile /> <Profile />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/packages/:packageId" element={ />
<Route
path="/packages/:packageId"
element={
<ProtectedRoute requirePermission="can_view_packages"> <ProtectedRoute requirePermission="can_view_packages">
<Layout> <Layout>
<PackageDetail /> <PackageDetail />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
/>
</Routes> </Routes>
) );
} }
function App() { function App() {
@@ -141,7 +179,7 @@ function App() {
</UpdateNotificationProvider> </UpdateNotificationProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </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 { import {
DndContext,
closestCenter, closestCenter,
DndContext,
KeyboardSensor, KeyboardSensor,
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
} from '@dnd-kit/core'; } from "@dnd-kit/core";
import { import {
arrayMove, arrayMove,
SortableContext, SortableContext,
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {
useSortable, useSortable,
} from '@dnd-kit/sortable'; verticalListSortingStrategy,
import { CSS } from '@dnd-kit/utilities'; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
X,
GripVertical,
Eye, Eye,
EyeOff, EyeOff,
Save, GripVertical,
RotateCcw, RotateCcw,
Settings as SettingsIcon Save,
} from 'lucide-react'; Settings as SettingsIcon,
import { dashboardPreferencesAPI } from '../utils/api'; X,
import { useTheme } from '../contexts/ThemeContext'; } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTheme } from "../contexts/ThemeContext";
import { dashboardPreferencesAPI } from "../utils/api";
// Sortable Card Item Component // Sortable Card Item Component
const SortableCardItem = ({ card, onToggle }) => { const SortableCardItem = ({ card, onToggle }) => {
@@ -53,7 +51,7 @@ const SortableCardItem = ({ card, onToggle }) => {
ref={setNodeRef} ref={setNodeRef}
style={style} 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 ${ 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"> <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"> <div className="text-sm font-medium text-secondary-900 dark:text-white">
{card.title} {card.title}
{card.typeLabel ? ( {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} ) : null}
</div> </div>
</div> </div>
@@ -78,8 +78,8 @@ const SortableCardItem = ({ card, onToggle }) => {
onClick={() => onToggle(card.cardId)} onClick={() => onToggle(card.cardId)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${ className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
card.enabled 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-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-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600"
}`} }`}
> >
{card.enabled ? ( {card.enabled ? (
@@ -108,21 +108,22 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
useSensor(PointerSensor), useSensor(PointerSensor),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}) }),
); );
// Fetch user's dashboard preferences // Fetch user's dashboard preferences
const { data: preferences, isLoading } = useQuery({ const { data: preferences, isLoading } = useQuery({
queryKey: ['dashboardPreferences'], queryKey: ["dashboardPreferences"],
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data), queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
enabled: isOpen enabled: isOpen,
}); });
// Fetch default card configuration // Fetch default card configuration
const { data: defaultCards } = useQuery({ const { data: defaultCards } = useQuery({
queryKey: ['dashboardDefaultCards'], queryKey: ["dashboardDefaultCards"],
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data), queryFn: () =>
enabled: isOpen dashboardPreferencesAPI.getDefaults().then((res) => res.data),
enabled: isOpen,
}); });
// Update preferences mutation // Update preferences mutation
@@ -130,15 +131,18 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences), mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
onSuccess: (response) => { onSuccess: (response) => {
// Optimistically update the query cache with the correct data structure // 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 // Also invalidate to ensure fresh data
queryClient.invalidateQueries(['dashboardPreferences']); queryClient.invalidateQueries(["dashboardPreferences"]);
setHasChanges(false); setHasChanges(false);
onClose(); onClose();
}, },
onError: (error) => { 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 // Initialize cards when preferences or defaults are loaded
@@ -152,14 +156,26 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
})); }));
const typeLabelFor = (cardId) => { const typeLabelFor = (cardId) => {
if (['totalHosts','hostsNeedingUpdates','totalOutdatedPackages','securityUpdates','upToDateHosts','totalHostGroups','totalUsers','totalRepos'].includes(cardId)) return 'Top card'; if (
if (cardId === 'osDistribution') return 'Pie chart'; [
if (cardId === 'osDistributionBar') return 'Bar chart'; "totalHosts",
if (cardId === 'updateStatus') return 'Pie chart'; "hostsNeedingUpdates",
if (cardId === 'packagePriority') return 'Pie chart'; "totalOutdatedPackages",
if (cardId === 'recentUsers') return 'Table'; "securityUpdates",
if (cardId === 'recentCollection') return 'Table'; "upToDateHosts",
if (cardId === 'quickStats') return 'Wide card'; "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; return undefined;
}; };
@@ -167,11 +183,13 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
const mergedCards = defaultCards const mergedCards = defaultCards
.map((defaultCard) => { .map((defaultCard) => {
const userPreference = normalizedPreferences.find( const userPreference = normalizedPreferences.find(
(p) => p.cardId === defaultCard.cardId (p) => p.cardId === defaultCard.cardId,
); );
return { return {
...defaultCard, ...defaultCard,
enabled: userPreference ? userPreference.enabled : defaultCard.enabled, enabled: userPreference
? userPreference.enabled
: defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order, order: userPreference ? userPreference.order : defaultCard.order,
typeLabel: typeLabelFor(defaultCard.cardId), typeLabel: typeLabelFor(defaultCard.cardId),
}; };
@@ -187,15 +205,15 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
if (active.id !== over.id) { if (active.id !== over.id) {
setCards((items) => { setCards((items) => {
const oldIndex = items.findIndex(item => item.cardId === active.id); const oldIndex = items.findIndex((item) => item.cardId === active.id);
const newIndex = items.findIndex(item => item.cardId === over.id); const newIndex = items.findIndex((item) => item.cardId === over.id);
const newItems = arrayMove(items, oldIndex, newIndex); const newItems = arrayMove(items, oldIndex, newIndex);
// Update order values // Update order values
return newItems.map((item, index) => ({ return newItems.map((item, index) => ({
...item, ...item,
order: index order: index,
})); }));
}); });
setHasChanges(true); setHasChanges(true);
@@ -203,21 +221,19 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
}; };
const handleToggle = (cardId) => { const handleToggle = (cardId) => {
setCards(prevCards => setCards((prevCards) =>
prevCards.map(card => prevCards.map((card) =>
card.cardId === cardId card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
? { ...card, enabled: !card.enabled } ),
: card
)
); );
setHasChanges(true); setHasChanges(true);
}; };
const handleSave = () => { const handleSave = () => {
const preferences = cards.map(card => ({ const preferences = cards.map((card) => ({
cardId: card.cardId, cardId: card.cardId,
enabled: card.enabled, enabled: card.enabled,
order: card.order order: card.order,
})); }));
updatePreferencesMutation.mutate(preferences); updatePreferencesMutation.mutate(preferences);
@@ -225,10 +241,10 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
const handleReset = () => { const handleReset = () => {
if (defaultCards) { if (defaultCards) {
const resetCards = defaultCards.map(card => ({ const resetCards = defaultCards.map((card) => ({
...card, ...card,
enabled: true, enabled: true,
order: card.order order: card.order,
})); }));
setCards(resetCards); setCards(resetCards);
setHasChanges(true); setHasChanges(true);
@@ -240,7 +256,10 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
return ( return (
<div className="fixed inset-0 z-50 overflow-y-auto"> <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="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="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="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> </div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6"> <p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Customize your dashboard by reordering cards and toggling their visibility. Customize your dashboard by reordering cards and toggling their
Drag cards to reorder them, and click the visibility toggle to show/hide cards. visibility. Drag cards to reorder them, and click the visibility
toggle to show/hide cards.
</p> </p>
{isLoading ? ( {isLoading ? (
@@ -274,7 +294,10 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} 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"> <div className="space-y-2 max-h-96 overflow-y-auto">
{cards.map((card) => ( {cards.map((card) => (
<SortableCardItem <SortableCardItem
@@ -295,8 +318,8 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
disabled={!hasChanges || updatePreferencesMutation.isPending} 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 ${ 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 !hasChanges || updatePreferencesMutation.isPending
? 'bg-secondary-400 cursor-not-allowed' ? "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-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`} }`}
> >
{updatePreferencesMutation.isPending ? ( {updatePreferencesMutation.isPending ? (

View File

@@ -1,108 +1,108 @@
import React, { useState } from 'react' import { AlertCircle, CheckCircle, Shield, UserPlus } from "lucide-react";
import { useAuth } from '../contexts/AuthContext' import React, { useState } from "react";
import { UserPlus, Shield, CheckCircle, AlertCircle } from 'lucide-react' import { useAuth } from "../contexts/AuthContext";
const FirstTimeAdminSetup = () => { const FirstTimeAdminSetup = () => {
const { login } = useAuth() const { login } = useAuth();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: '', username: "",
email: '', email: "",
password: '', password: "",
confirmPassword: '', confirmPassword: "",
firstName: '', firstName: "",
lastName: '' lastName: "",
}) });
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false);
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value } = e.target const { name, value } = e.target;
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
[name]: value [name]: value,
})) }));
// Clear error when user starts typing // Clear error when user starts typing
if (error) setError('') if (error) setError("");
} };
const validateForm = () => { const validateForm = () => {
if (!formData.firstName.trim()) { if (!formData.firstName.trim()) {
setError('First name is required') setError("First name is required");
return false return false;
} }
if (!formData.lastName.trim()) { if (!formData.lastName.trim()) {
setError('Last name is required') setError("Last name is required");
return false return false;
} }
if (!formData.username.trim()) { if (!formData.username.trim()) {
setError('Username is required') setError("Username is required");
return false return false;
} }
if (!formData.email.trim()) { if (!formData.email.trim()) {
setError('Email address is required') setError("Email address is required");
return false return false;
} }
// Enhanced email validation // Enhanced email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email.trim())) { if (!emailRegex.test(formData.email.trim())) {
setError('Please enter a valid email address (e.g., user@example.com)') setError("Please enter a valid email address (e.g., user@example.com)");
return false return false;
} }
if (formData.password.length < 8) { if (formData.password.length < 8) {
setError('Password must be at least 8 characters for security') setError("Password must be at least 8 characters for security");
return false return false;
} }
if (formData.password !== formData.confirmPassword) { if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match') setError("Passwords do not match");
return false return false;
}
return true
} }
return true;
};
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault();
if (!validateForm()) return if (!validateForm()) return;
setIsLoading(true) setIsLoading(true);
setError('') setError("");
try { try {
const response = await fetch('/api/v1/auth/setup-admin', { const response = await fetch("/api/v1/auth/setup-admin", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
username: formData.username.trim(), username: formData.username.trim(),
email: formData.email.trim(), email: formData.email.trim(),
password: formData.password, password: formData.password,
firstName: formData.firstName.trim(), 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) { if (response.ok) {
setSuccess(true) setSuccess(true);
// Auto-login the user after successful setup // Auto-login the user after successful setup
setTimeout(() => { setTimeout(() => {
login(formData.username.trim(), formData.password) login(formData.username.trim(), formData.password);
}, 2000) }, 2000);
} else { } else {
setError(data.error || 'Failed to create admin user') setError(data.error || "Failed to create admin user");
} }
} catch (error) { } catch (error) {
console.error('Setup error:', error) console.error("Setup error:", error);
setError('Network error. Please try again.') setError("Network error. Please try again.");
} finally { } finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
if (success) { if (success) {
return ( return (
@@ -118,7 +118,8 @@ const FirstTimeAdminSetup = () => {
Admin Account Created! Admin Account Created!
</h1> </h1>
<p className="text-secondary-600 dark:text-secondary-300 mb-6"> <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> </p>
<div className="flex justify-center"> <div className="flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div> <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> </div>
</div> </div>
) );
} }
return ( 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="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"> <div className="flex items-center">
<AlertCircle className="h-5 w-5 text-danger-600 dark:text-danger-400 mr-2" /> <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>
</div> </div>
)} )}
@@ -159,7 +162,10 @@ const FirstTimeAdminSetup = () => {
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <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 First Name
</label> </label>
<input <input
@@ -175,7 +181,10 @@ const FirstTimeAdminSetup = () => {
/> />
</div> </div>
<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 Last Name
</label> </label>
<input <input
@@ -193,7 +202,10 @@ const FirstTimeAdminSetup = () => {
</div> </div>
<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 Username
</label> </label>
<input <input
@@ -210,7 +222,10 @@ const FirstTimeAdminSetup = () => {
</div> </div>
<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 Email Address
</label> </label>
<input <input
@@ -227,7 +242,10 @@ const FirstTimeAdminSetup = () => {
</div> </div>
<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 Password
</label> </label>
<input <input
@@ -244,7 +262,10 @@ const FirstTimeAdminSetup = () => {
</div> </div>
<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 Confirm Password
</label> </label>
<input <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" /> <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"> <div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium mb-1">Admin Privileges</p> <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>
</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 { Check, Edit2, X } from "lucide-react";
import { Edit2, Check, X } from 'lucide-react'; import React, { useEffect, useRef, useState } from "react";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
const InlineEdit = ({ const InlineEdit = ({
value, value,
@@ -11,12 +11,12 @@ const InlineEdit = ({
className = "", className = "",
disabled = false, disabled = false,
validate = null, validate = null,
linkTo = null linkTo = null,
}) => { }) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value); const [editValue, setEditValue] = useState(value);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState("");
const inputRef = useRef(null); const inputRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -34,13 +34,13 @@ const InlineEdit = ({
if (disabled) return; if (disabled) return;
setIsEditing(true); setIsEditing(true);
setEditValue(value); setEditValue(value);
setError(''); setError("");
}; };
const handleCancel = () => { const handleCancel = () => {
setIsEditing(false); setIsEditing(false);
setEditValue(value); setEditValue(value);
setError(''); setError("");
if (onCancel) onCancel(); if (onCancel) onCancel();
}; };
@@ -63,23 +63,23 @@ const InlineEdit = ({
} }
setIsLoading(true); setIsLoading(true);
setError(''); setError("");
try { try {
await onSave(editValue.trim()); await onSave(editValue.trim());
setIsEditing(false); setIsEditing(false);
} catch (err) { } catch (err) {
setError(err.message || 'Failed to save'); setError(err.message || "Failed to save");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
handleSave(); handleSave();
} else if (e.key === 'Escape') { } else if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
handleCancel(); handleCancel();
} }
@@ -98,12 +98,12 @@ const InlineEdit = ({
maxLength={maxLength} maxLength={maxLength}
disabled={isLoading} 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 ${ 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' : '' error ? "border-red-500" : ""
} ${isLoading ? 'opacity-50' : ''}`} } ${isLoading ? "opacity-50" : ""}`}
/> />
<button <button
onClick={handleSave} 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" 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" title="Save"
> >
@@ -118,7 +118,9 @@ const InlineEdit = ({
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
{error && ( {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> </div>
); );

View File

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

View File

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

View File

@@ -1,20 +1,24 @@
import React from 'react' import React from "react";
import { Navigate } from 'react-router-dom' import { Navigate } from "react-router-dom";
import { useAuth } from '../contexts/AuthContext' import { useAuth } from "../contexts/AuthContext";
const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => { const ProtectedRoute = ({
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth() children,
requireAdmin = false,
requirePermission = null,
}) => {
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth();
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <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 className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div> </div>
) );
} }
if (!isAuthenticated()) { if (!isAuthenticated()) {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />;
} }
// Check admin requirement // Check admin requirement
@@ -22,11 +26,15 @@ const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = nu
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2> <h2 className="text-xl font-semibold text-secondary-900 mb-2">
<p className="text-secondary-600">You don't have permission to access this page.</p> Access Denied
</h2>
<p className="text-secondary-600">
You don't have permission to access this page.
</p>
</div> </div>
</div> </div>
) );
} }
// Check specific permission requirement // Check specific permission requirement
@@ -34,14 +42,18 @@ const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = nu
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2> <h2 className="text-xl font-semibold text-secondary-900 mb-2">
<p className="text-secondary-600">You don't have permission to access this page.</p> Access Denied
</h2>
<p className="text-secondary-600">
You don't have permission to access this page.
</p>
</div> </div>
</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 }) => { const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
if (!show) return null if (!show) return null;
return ( return (
<ArrowUpCircle <ArrowUpCircle
className={`${className} text-red-500 animate-pulse`} className={`${className} text-red-500 animate-pulse`}
title="Update available" 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 = () => { export const useAuth = () => {
const context = useContext(AuthContext) const context = useContext(AuthContext);
if (!context) { if (!context) {
throw new Error('useAuth must be used within an AuthProvider') throw new Error("useAuth must be used within an AuthProvider");
} }
return context return context;
} };
export const AuthProvider = ({ children }) => { export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null) const [user, setUser] = useState(null);
const [token, setToken] = useState(null) const [token, setToken] = useState(null);
const [permissions, setPermissions] = useState(null) const [permissions, setPermissions] = useState(null);
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true);
const [permissionsLoading, setPermissionsLoading] = useState(false) const [permissionsLoading, setPermissionsLoading] = useState(false);
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false) const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false);
const [checkingSetup, setCheckingSetup] = useState(true) const [checkingSetup, setCheckingSetup] = useState(true);
// Initialize auth state from localStorage // Initialize auth state from localStorage
useEffect(() => { useEffect(() => {
const storedToken = localStorage.getItem('token') const storedToken = localStorage.getItem("token");
const storedUser = localStorage.getItem('user') const storedUser = localStorage.getItem("user");
const storedPermissions = localStorage.getItem('permissions') const storedPermissions = localStorage.getItem("permissions");
if (storedToken && storedUser) { if (storedToken && storedUser) {
try { try {
setToken(storedToken) setToken(storedToken);
setUser(JSON.parse(storedUser)) setUser(JSON.parse(storedUser));
if (storedPermissions) { if (storedPermissions) {
setPermissions(JSON.parse(storedPermissions)) setPermissions(JSON.parse(storedPermissions));
} else { } else {
// Fetch permissions if not stored // Fetch permissions if not stored
fetchPermissions(storedToken) fetchPermissions(storedToken);
} }
} catch (error) { } catch (error) {
console.error('Error parsing stored user data:', error) console.error("Error parsing stored user data:", error);
localStorage.removeItem('token') localStorage.removeItem("token");
localStorage.removeItem('user') localStorage.removeItem("user");
localStorage.removeItem('permissions') localStorage.removeItem("permissions");
} }
} }
setIsLoading(false) setIsLoading(false);
}, []) }, []);
// Refresh permissions when user logs in (no automatic refresh) // Refresh permissions when user logs in (no automatic refresh)
useEffect(() => { useEffect(() => {
if (token && user) { if (token && user) {
// Only refresh permissions once when user logs in // Only refresh permissions once when user logs in
refreshPermissions() refreshPermissions();
} }
}, [token, user]) }, [token, user]);
const fetchPermissions = async (authToken) => { const fetchPermissions = async (authToken) => {
try { try {
setPermissionsLoading(true) setPermissionsLoading(true);
const response = await fetch('/api/v1/permissions/user-permissions', { const response = await fetch("/api/v1/permissions/user-permissions", {
headers: { headers: {
'Authorization': `Bearer ${authToken}`, Authorization: `Bearer ${authToken}`,
}, },
}) });
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json();
setPermissions(data) setPermissions(data);
localStorage.setItem('permissions', JSON.stringify(data)) localStorage.setItem("permissions", JSON.stringify(data));
return data return data;
} else { } else {
console.error('Failed to fetch permissions') console.error("Failed to fetch permissions");
return null return null;
} }
} catch (error) { } catch (error) {
console.error('Error fetching permissions:', error) console.error("Error fetching permissions:", error);
return null return null;
} finally { } finally {
setPermissionsLoading(false) setPermissionsLoading(false);
}
} }
};
const refreshPermissions = async () => { const refreshPermissions = async () => {
if (token) { if (token) {
const updatedPermissions = await fetchPermissions(token) const updatedPermissions = await fetchPermissions(token);
return updatedPermissions return updatedPermissions;
}
return null
} }
return null;
};
const login = async (username, password) => { const login = async (username, password) => {
try { try {
const response = await fetch('/api/v1/auth/login', { const response = await fetch("/api/v1/auth/login", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}) });
const data = await response.json() const data = await response.json();
if (response.ok) { if (response.ok) {
setToken(data.token) setToken(data.token);
setUser(data.user) setUser(data.user);
localStorage.setItem('token', data.token) localStorage.setItem("token", data.token);
localStorage.setItem('user', JSON.stringify(data.user)) localStorage.setItem("user", JSON.stringify(data.user));
// Fetch user permissions after successful login // Fetch user permissions after successful login
const userPermissions = await fetchPermissions(data.token) const userPermissions = await fetchPermissions(data.token);
if (userPermissions) { if (userPermissions) {
setPermissions(userPermissions) setPermissions(userPermissions);
} }
return { success: true } return { success: true };
} else { } else {
return { success: false, error: data.error || 'Login failed' } return { success: false, error: data.error || "Login failed" };
} }
} catch (error) { } catch (error) {
return { success: false, error: 'Network error occurred' } return { success: false, error: "Network error occurred" };
}
} }
};
const logout = async () => { const logout = async () => {
try { try {
if (token) { if (token) {
await fetch('/api/v1/auth/logout', { await fetch("/api/v1/auth/logout", {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}) });
} }
} catch (error) { } catch (error) {
console.error('Logout error:', error) console.error("Logout error:", error);
} finally { } finally {
setToken(null) setToken(null);
setUser(null) setUser(null);
setPermissions(null) setPermissions(null);
localStorage.removeItem('token') localStorage.removeItem("token");
localStorage.removeItem('user') localStorage.removeItem("user");
localStorage.removeItem('permissions') localStorage.removeItem("permissions");
}
} }
};
const updateProfile = async (profileData) => { const updateProfile = async (profileData) => {
try { try {
const response = await fetch('/api/v1/auth/profile', { const response = await fetch("/api/v1/auth/profile", {
method: 'PUT', method: "PUT",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify(profileData), body: JSON.stringify(profileData),
}) });
const data = await response.json() const data = await response.json();
if (response.ok) { if (response.ok) {
setUser(data.user) setUser(data.user);
localStorage.setItem('user', JSON.stringify(data.user)) localStorage.setItem("user", JSON.stringify(data.user));
return { success: true, user: data.user } return { success: true, user: data.user };
} else { } else {
return { success: false, error: data.error || 'Update failed' } return { success: false, error: data.error || "Update failed" };
} }
} catch (error) { } catch (error) {
return { success: false, error: 'Network error occurred' } return { success: false, error: "Network error occurred" };
}
} }
};
const changePassword = async (currentPassword, newPassword) => { const changePassword = async (currentPassword, newPassword) => {
try { try {
const response = await fetch('/api/v1/auth/change-password', { const response = await fetch("/api/v1/auth/change-password", {
method: 'PUT', method: "PUT",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ currentPassword, newPassword }), body: JSON.stringify({ currentPassword, newPassword }),
}) });
const data = await response.json() const data = await response.json();
if (response.ok) { if (response.ok) {
return { success: true } return { success: true };
} else { } else {
return { success: false, error: data.error || 'Password change failed' } return {
success: false,
error: data.error || "Password change failed",
};
} }
} catch (error) { } catch (error) {
return { success: false, error: 'Network error occurred' } return { success: false, error: "Network error occurred" };
}
} }
};
const isAuthenticated = () => { const isAuthenticated = () => {
return !!(token && user) return !!(token && user);
} };
const isAdmin = () => { const isAdmin = () => {
return user?.role === 'admin' return user?.role === "admin";
} };
// Permission checking functions // Permission checking functions
const hasPermission = (permission) => { const hasPermission = (permission) => {
// If permissions are still loading, return false to show loading state // If permissions are still loading, return false to show loading state
if (permissionsLoading) { if (permissionsLoading) {
return false return false;
}
return permissions?.[permission] === true
} }
return permissions?.[permission] === true;
};
const canViewDashboard = () => hasPermission('can_view_dashboard') const canViewDashboard = () => hasPermission("can_view_dashboard");
const canViewHosts = () => hasPermission('can_view_hosts') const canViewHosts = () => hasPermission("can_view_hosts");
const canManageHosts = () => hasPermission('can_manage_hosts') const canManageHosts = () => hasPermission("can_manage_hosts");
const canViewPackages = () => hasPermission('can_view_packages') const canViewPackages = () => hasPermission("can_view_packages");
const canManagePackages = () => hasPermission('can_manage_packages') const canManagePackages = () => hasPermission("can_manage_packages");
const canViewUsers = () => hasPermission('can_view_users') const canViewUsers = () => hasPermission("can_view_users");
const canManageUsers = () => hasPermission('can_manage_users') const canManageUsers = () => hasPermission("can_manage_users");
const canViewReports = () => hasPermission('can_view_reports') const canViewReports = () => hasPermission("can_view_reports");
const canExportData = () => hasPermission('can_export_data') const canExportData = () => hasPermission("can_export_data");
const canManageSettings = () => hasPermission('can_manage_settings') const canManageSettings = () => hasPermission("can_manage_settings");
// Check if any admin users exist (for first-time setup) // Check if any admin users exist (for first-time setup)
const checkAdminUsersExist = useCallback(async () => { const checkAdminUsersExist = useCallback(async () => {
try { try {
const response = await fetch('/api/v1/auth/check-admin-users', { const response = await fetch("/api/v1/auth/check-admin-users", {
method: 'GET', method: "GET",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}) });
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json();
setNeedsFirstTimeSetup(!data.hasAdminUsers) setNeedsFirstTimeSetup(!data.hasAdminUsers);
} else { } else {
// If endpoint doesn't exist or fails, assume setup is needed // If endpoint doesn't exist or fails, assume setup is needed
setNeedsFirstTimeSetup(true) setNeedsFirstTimeSetup(true);
} }
} catch (error) { } 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 // If there's an error, assume setup is needed
setNeedsFirstTimeSetup(true) setNeedsFirstTimeSetup(true);
} finally { } finally {
setCheckingSetup(false) setCheckingSetup(false);
} }
}, []) }, []);
// Check for admin users on initial load // Check for admin users on initial load
useEffect(() => { useEffect(() => {
if (!token && !user) { if (!token && !user) {
checkAdminUsersExist() checkAdminUsersExist();
} else { } else {
setCheckingSetup(false) setCheckingSetup(false);
} }
}, [token, user, checkAdminUsersExist]) }, [token, user, checkAdminUsersExist]);
const setAuthState = (authToken, authUser) => { const setAuthState = (authToken, authUser) => {
setToken(authToken) setToken(authToken);
setUser(authUser) setUser(authUser);
localStorage.setItem('token', authToken) localStorage.setItem("token", authToken);
localStorage.setItem('user', JSON.stringify(authUser)) localStorage.setItem("user", JSON.stringify(authUser));
} };
const value = { const value = {
user, user,
@@ -287,12 +296,8 @@ export const AuthProvider = ({ children }) => {
canManageUsers, canManageUsers,
canViewReports, canViewReports,
canExportData, canExportData,
canManageSettings canManageSettings,
} };
return ( return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
<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 = () => { export const useTheme = () => {
const context = useContext(ThemeContext) const context = useContext(ThemeContext);
if (!context) { if (!context) {
throw new Error('useTheme must be used within a ThemeProvider') throw new Error("useTheme must be used within a ThemeProvider");
} }
return context return context;
} };
export const ThemeProvider = ({ children }) => { export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => { const [theme, setTheme] = useState(() => {
// Check localStorage first, then system preference // Check localStorage first, then system preference
const savedTheme = localStorage.getItem('theme') const savedTheme = localStorage.getItem("theme");
if (savedTheme) { if (savedTheme) {
return savedTheme return savedTheme;
} }
// Check system preference // Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) { if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return 'dark' return "dark";
} }
return 'light' return "light";
}) });
useEffect(() => { useEffect(() => {
// Apply theme to document // Apply theme to document
if (theme === 'dark') { if (theme === "dark") {
document.documentElement.classList.add('dark') document.documentElement.classList.add("dark");
} else { } else {
document.documentElement.classList.remove('dark') document.documentElement.classList.remove("dark");
} }
// Save to localStorage // Save to localStorage
localStorage.setItem('theme', theme) localStorage.setItem("theme", theme);
}, [theme]) }, [theme]);
const toggleTheme = () => { const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light') setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
} };
const value = { const value = {
theme, theme,
toggleTheme, toggleTheme,
isDark: theme === 'dark' isDark: theme === "dark",
} };
return ( return (
<ThemeContext.Provider value={value}> <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
{children} );
</ThemeContext.Provider> };
)
}

View File

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

View File

@@ -1,9 +1,9 @@
import React from 'react' import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ReactDOM from 'react-dom/client' import React from "react";
import { BrowserRouter } from 'react-router-dom' import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter } from "react-router-dom";
import App from './App.jsx' import App from "./App.jsx";
import './index.css' import "./index.css";
// Create a client for React Query // Create a client for React Query
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -14,9 +14,9 @@ const queryClient = new QueryClient({
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
}, },
}, },
}) });
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -24,4 +24,4 @@ ReactDOM.createRoot(document.getElementById('root')).render(
</QueryClientProvider> </QueryClientProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { import {
Plus,
Edit,
Trash2,
Server,
Users,
AlertTriangle, AlertTriangle,
CheckCircle CheckCircle,
} from 'lucide-react' Edit,
import { hostGroupsAPI } from '../utils/api' Plus,
Server,
Trash2,
Users,
} from "lucide-react";
import React, { useState } from "react";
import { hostGroupsAPI } from "../utils/api";
const HostGroups = () => { const HostGroups = () => {
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false) const [showEditModal, setShowEditModal] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(null) const [selectedGroup, setSelectedGroup] = useState(null);
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false);
const [groupToDelete, setGroupToDelete] = useState(null) const [groupToDelete, setGroupToDelete] = useState(null);
const queryClient = useQueryClient() const queryClient = useQueryClient();
// Fetch host groups // Fetch host groups
const { data: hostGroups, isLoading, error } = useQuery({ const {
queryKey: ['hostGroups'], data: hostGroups,
queryFn: () => hostGroupsAPI.list().then(res => res.data), isLoading,
}) error,
} = useQuery({
queryKey: ["hostGroups"],
queryFn: () => hostGroupsAPI.list().then((res) => res.data),
});
// Create host group mutation // Create host group mutation
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data) => hostGroupsAPI.create(data), mutationFn: (data) => hostGroupsAPI.create(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(['hostGroups']) queryClient.invalidateQueries(["hostGroups"]);
setShowCreateModal(false) setShowCreateModal(false);
}, },
onError: (error) => { onError: (error) => {
console.error('Failed to create host group:', error) console.error("Failed to create host group:", error);
} },
}) });
// Update host group mutation // Update host group mutation
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data), mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(['hostGroups']) queryClient.invalidateQueries(["hostGroups"]);
setShowEditModal(false) setShowEditModal(false);
setSelectedGroup(null) setSelectedGroup(null);
}, },
onError: (error) => { onError: (error) => {
console.error('Failed to update host group:', error) console.error("Failed to update host group:", error);
} },
}) });
// Delete host group mutation // Delete host group mutation
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id) => hostGroupsAPI.delete(id), mutationFn: (id) => hostGroupsAPI.delete(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(['hostGroups']) queryClient.invalidateQueries(["hostGroups"]);
setShowDeleteModal(false) setShowDeleteModal(false);
setGroupToDelete(null) setGroupToDelete(null);
}, },
onError: (error) => { onError: (error) => {
console.error('Failed to delete host group:', error) console.error("Failed to delete host group:", error);
} },
}) });
const handleCreate = (data) => { const handleCreate = (data) => {
createMutation.mutate(data) createMutation.mutate(data);
} };
const handleEdit = (group) => { const handleEdit = (group) => {
setSelectedGroup(group) setSelectedGroup(group);
setShowEditModal(true) setShowEditModal(true);
} };
const handleUpdate = (data) => { const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data }) updateMutation.mutate({ id: selectedGroup.id, data });
} };
const handleDeleteClick = (group) => { const handleDeleteClick = (group) => {
setGroupToDelete(group) setGroupToDelete(group);
setShowDeleteModal(true) setShowDeleteModal(true);
} };
const handleDeleteConfirm = () => { const handleDeleteConfirm = () => {
deleteMutation.mutate(groupToDelete.id) deleteMutation.mutate(groupToDelete.id);
} };
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <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 className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div> </div>
) );
} }
if (error) { if (error) {
@@ -104,12 +108,12 @@ const HostGroups = () => {
Error loading host groups Error loading host groups
</h3> </h3>
<p className="text-sm text-danger-700 mt-1"> <p className="text-sm text-danger-700 mt-1">
{error.message || 'Failed to load host groups'} {error.message || "Failed to load host groups"}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
@@ -134,7 +138,10 @@ const HostGroups = () => {
{hostGroups && hostGroups.length > 0 ? ( {hostGroups && hostGroups.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{hostGroups.map((group) => ( {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-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <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="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Server className="h-4 w-4" /> <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> </div>
</div> </div>
@@ -212,8 +222,8 @@ const HostGroups = () => {
<EditHostGroupModal <EditHostGroupModal
group={selectedGroup} group={selectedGroup}
onClose={() => { onClose={() => {
setShowEditModal(false) setShowEditModal(false);
setSelectedGroup(null) setSelectedGroup(null);
}} }}
onSubmit={handleUpdate} onSubmit={handleUpdate}
isLoading={updateMutation.isPending} isLoading={updateMutation.isPending}
@@ -225,36 +235,36 @@ const HostGroups = () => {
<DeleteHostGroupModal <DeleteHostGroupModal
group={groupToDelete} group={groupToDelete}
onClose={() => { onClose={() => {
setShowDeleteModal(false) setShowDeleteModal(false);
setGroupToDelete(null) setGroupToDelete(null);
}} }}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
isLoading={deleteMutation.isPending} isLoading={deleteMutation.isPending}
/> />
)} )}
</div> </div>
) );
} };
// Create Host Group Modal // Create Host Group Modal
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => { const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: "",
description: '', description: "",
color: '#3B82F6' color: "#3B82F6",
}) });
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault() e.preventDefault();
onSubmit(formData) onSubmit(formData);
} };
const handleChange = (e) => { const handleChange = (e) => {
setFormData({ setFormData({
...formData, ...formData,
[e.target.name]: e.target.value [e.target.name]: e.target.value,
}) });
} };
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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 Cancel
</button> </button>
<button <button type="submit" className="btn-primary" disabled={isLoading}>
type="submit" {isLoading ? "Creating..." : "Create Group"}
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Creating...' : 'Create Group'}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
) );
} };
// Edit Host Group Modal // Edit Host Group Modal
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => { const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: group.name, name: group.name,
description: group.description || '', description: group.description || "",
color: group.color || '#3B82F6' color: group.color || "#3B82F6",
}) });
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault() e.preventDefault();
onSubmit(formData) onSubmit(formData);
} };
const handleChange = (e) => { const handleChange = (e) => {
setFormData({ setFormData({
...formData, ...formData,
[e.target.name]: e.target.value [e.target.name]: e.target.value,
}) });
} };
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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 Cancel
</button> </button>
<button <button type="submit" className="btn-primary" disabled={isLoading}>
type="submit" {isLoading ? "Updating..." : "Update Group"}
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Updating...' : 'Update Group'}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
) );
} };
// Delete Confirmation Modal // Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
@@ -461,13 +463,14 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
<div className="mb-6"> <div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200"> <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>? <span className="font-semibold">"{group.name}"</span>?
</p> </p>
{group._count.hosts > 0 && ( {group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md"> <div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800"> <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. You must move or remove these hosts before deleting the group.
</p> </p>
</div> </div>
@@ -487,12 +490,12 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
className="btn-danger" className="btn-danger"
disabled={isLoading || group._count.hosts > 0} disabled={isLoading || group._count.hosts > 0}
> >
{isLoading ? 'Deleting...' : 'Delete Group'} {isLoading ? "Deleting..." : "Delete Group"}
</button> </button>
</div> </div>
</div> </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 {
import { useNavigate } from 'react-router-dom' AlertCircle,
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft, Mail } from 'lucide-react' ArrowLeft,
import { useAuth } from '../contexts/AuthContext' Eye,
import { authAPI } from '../utils/api' 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 Login = () => {
const [isSignupMode, setIsSignupMode] = useState(false) const [isSignupMode, setIsSignupMode] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: '', username: "",
email: '', email: "",
password: '', password: "",
firstName: '', firstName: "",
lastName: '' lastName: "",
}) });
const [tfaData, setTfaData] = useState({ const [tfaData, setTfaData] = useState({
token: '' token: "",
}) });
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [requiresTfa, setRequiresTfa] = useState(false) const [requiresTfa, setRequiresTfa] = useState(false);
const [tfaUsername, setTfaUsername] = useState('') const [tfaUsername, setTfaUsername] = useState("");
const [signupEnabled, setSignupEnabled] = useState(false) const [signupEnabled, setSignupEnabled] = useState(false);
const navigate = useNavigate() const navigate = useNavigate();
const { login, setAuthState } = useAuth() const { login, setAuthState } = useAuth();
// Check if signup is enabled // Check if signup is enabled
useEffect(() => { useEffect(() => {
const checkSignupEnabled = async () => { const checkSignupEnabled = async () => {
try { try {
const response = await fetch('/api/v1/auth/signup-enabled') const response = await fetch("/api/v1/auth/signup-enabled");
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json();
setSignupEnabled(data.signupEnabled) setSignupEnabled(data.signupEnabled);
} }
} catch (error) { } 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 // Default to disabled on error for security
setSignupEnabled(false) setSignupEnabled(false);
} }
} };
checkSignupEnabled() checkSignupEnabled();
}, []) }, []);
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError("");
try { try {
const response = await authAPI.login(formData.username, formData.password) const response = await authAPI.login(
formData.username,
formData.password,
);
if (response.data.requiresTfa) { if (response.data.requiresTfa) {
setRequiresTfa(true) setRequiresTfa(true);
setTfaUsername(formData.username) setTfaUsername(formData.username);
setError('') setError("");
} else { } else {
// Regular login successful // Regular login successful
const result = await login(formData.username, formData.password) const result = await login(formData.username, formData.password);
if (result.success) { if (result.success) {
navigate('/') navigate("/");
} else { } else {
setError(result.error || 'Login failed') setError(result.error || "Login failed");
} }
} }
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Login failed') setError(err.response?.data?.error || "Login failed");
} finally { } finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
const handleSignupSubmit = async (e) => { const handleSignupSubmit = async (e) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError("");
try { 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) { if (response.data && response.data.token) {
// Update AuthContext state and localStorage // Update AuthContext state and localStorage
setAuthState(response.data.token, response.data.user) setAuthState(response.data.token, response.data.user);
// Redirect to dashboard // Redirect to dashboard
navigate('/') navigate("/");
} else { } else {
setError('Signup failed - invalid response') setError("Signup failed - invalid response");
} }
} catch (err) { } catch (err) {
console.error('Signup error:', err) console.error("Signup error:", err);
const errorMessage = err.response?.data?.error || const errorMessage =
err.response?.data?.error ||
(err.response?.data?.errors && err.response.data.errors.length > 0 (err.response?.data?.errors && err.response.data.errors.length > 0
? err.response.data.errors.map(e => e.msg).join(', ') ? err.response.data.errors.map((e) => e.msg).join(", ")
: err.message || 'Signup failed') : err.message || "Signup failed");
setError(errorMessage) setError(errorMessage);
} finally { } finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
const handleTfaSubmit = async (e) => { const handleTfaSubmit = async (e) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError("");
try { try {
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token) const response = await authAPI.verifyTfa(tfaUsername, tfaData.token);
if (response.data && response.data.token) { if (response.data && response.data.token) {
// Store token and user data // Store token and user data
localStorage.setItem('token', response.data.token) localStorage.setItem("token", response.data.token);
localStorage.setItem('user', JSON.stringify(response.data.user)) localStorage.setItem("user", JSON.stringify(response.data.user));
// Redirect to dashboard // Redirect to dashboard
navigate('/') navigate("/");
} else { } else {
setError('TFA verification failed - invalid response') setError("TFA verification failed - invalid response");
} }
} catch (err) { } catch (err) {
console.error('TFA verification error:', err) console.error("TFA verification error:", err);
const errorMessage = err.response?.data?.error || err.message || 'TFA verification failed' const errorMessage =
setError(errorMessage) err.response?.data?.error || err.message || "TFA verification failed";
setError(errorMessage);
// Clear the token input for security // Clear the token input for security
setTfaData({ token: '' }) setTfaData({ token: "" });
} finally { } finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
const handleInputChange = (e) => { const handleInputChange = (e) => {
setFormData({ setFormData({
...formData, ...formData,
[e.target.name]: e.target.value [e.target.name]: e.target.value,
}) });
} };
const handleTfaInputChange = (e) => { const handleTfaInputChange = (e) => {
setTfaData({ setTfaData({
...tfaData, ...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 // Clear error when user starts typing
if (error) { if (error) {
setError('') setError("");
}
} }
};
const handleBackToLogin = () => { const handleBackToLogin = () => {
setRequiresTfa(false) setRequiresTfa(false);
setTfaData({ token: '' }) setTfaData({ token: "" });
setError('') setError("");
} };
const toggleMode = () => { const toggleMode = () => {
// Only allow signup mode if signup is enabled // Only allow signup mode if signup is enabled
if (!signupEnabled && !isSignupMode) { 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({ setFormData({
username: '', username: "",
email: '', email: "",
password: '', password: "",
firstName: '', firstName: "",
lastName: '' lastName: "",
}) });
setError('') setError("");
} };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8"> <div className="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} /> <Lock size={24} color="#2563eb" strokeWidth={2} />
</div> </div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900"> <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> </h2>
<p className="mt-2 text-center text-sm text-secondary-600"> <p className="mt-2 text-center text-sm text-secondary-600">
Monitor and manage your Linux package updates Monitor and manage your Linux package updates
@@ -185,11 +205,17 @@ const Login = () => {
</div> </div>
{!requiresTfa ? ( {!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 className="space-y-4">
<div> <div>
<label htmlFor="username" className="block text-sm font-medium text-secondary-700"> <label
{isSignupMode ? 'Username' : 'Username or Email'} htmlFor="username"
className="block text-sm font-medium text-secondary-700"
>
{isSignupMode ? "Username" : "Username or Email"}
</label> </label>
<div className="mt-1 relative"> <div className="mt-1 relative">
<input <input
@@ -200,14 +226,14 @@ const Login = () => {
value={formData.username} value={formData.username}
onChange={handleInputChange} 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" 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"> <div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<User <User size={20} color="#64748b" strokeWidth={2} />
size={20}
color="#64748b"
strokeWidth={2}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -216,7 +242,10 @@ const Login = () => {
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <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 First Name
</label> </label>
<div className="mt-1 relative"> <div className="mt-1 relative">
@@ -236,7 +265,10 @@ const Login = () => {
</div> </div>
</div> </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 Last Name
</label> </label>
<div className="mt-1 relative"> <div className="mt-1 relative">
@@ -257,7 +289,10 @@ const Login = () => {
</div> </div>
</div> </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 Email
</label> </label>
<div className="mt-1 relative"> <div className="mt-1 relative">
@@ -272,11 +307,7 @@ const Login = () => {
placeholder="Enter your email" placeholder="Enter your email"
/> />
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center"> <div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<Mail <Mail size={20} color="#64748b" strokeWidth={2} />
size={20}
color="#64748b"
strokeWidth={2}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -284,14 +315,17 @@ const Login = () => {
)} )}
<div> <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 Password
</label> </label>
<div className="mt-1 relative"> <div className="mt-1 relative">
<input <input
id="password" id="password"
name="password" name="password"
type={showPassword ? 'text' : 'password'} type={showPassword ? "text" : "password"}
required required
value={formData.password} value={formData.password}
onChange={handleInputChange} onChange={handleInputChange}
@@ -299,11 +333,7 @@ const Login = () => {
placeholder="Enter your password" placeholder="Enter your password"
/> />
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center"> <div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<Lock <Lock size={20} color="#64748b" strokeWidth={2} />
size={20}
color="#64748b"
strokeWidth={2}
/>
</div> </div>
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center"> <div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
<button <button
@@ -342,10 +372,12 @@ const Login = () => {
{isLoading ? ( {isLoading ? (
<div className="flex items-center"> <div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <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> </div>
) : isSignupMode ? (
"Create Account"
) : ( ) : (
isSignupMode ? 'Create Account' : 'Sign in' "Sign in"
)} )}
</button> </button>
</div> </div>
@@ -353,13 +385,15 @@ const Login = () => {
{signupEnabled && ( {signupEnabled && (
<div className="text-center"> <div className="text-center">
<p className="text-sm text-secondary-600"> <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 <button
type="button" type="button"
onClick={toggleMode} onClick={toggleMode}
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline" 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> </button>
</p> </p>
</div> </div>
@@ -380,7 +414,10 @@ const Login = () => {
</div> </div>
<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 Verification Code
</label> </label>
<div className="mt-1"> <div className="mt-1">
@@ -421,7 +458,7 @@ const Login = () => {
Verifying... Verifying...
</div> </div>
) : ( ) : (
'Verify Code' "Verify Code"
)} )}
</button> </button>
@@ -444,7 +481,7 @@ const Login = () => {
)} )}
</div> </div>
</div> </div>
) );
} };
export default Login export default Login;

View File

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

View File

@@ -1,25 +1,27 @@
import React from 'react' import { Package } from "lucide-react";
import { useParams } from 'react-router-dom' import React from "react";
import { Package } from 'lucide-react' import { useParams } from "react-router-dom";
const PackageDetail = () => { const PackageDetail = () => {
const { packageId } = useParams() const { packageId } = useParams();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="card p-8 text-center"> <div className="card p-8 text-center">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" /> <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"> <p className="text-secondary-600">
Detailed view for package: {packageId} Detailed view for package: {packageId}
</p> </p>
<p className="text-secondary-600 mt-2"> <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> </p>
</div> </div>
</div> </div>
) );
} };
export default PackageDetail export default PackageDetail;

View File

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

View File

@@ -1,80 +1,89 @@
import React, { useState, useEffect } from 'react' import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { import {
Shield, AlertTriangle,
Settings,
Users,
Server,
Package,
BarChart3, BarChart3,
Download, Download,
Eye,
Edit, Edit,
Trash2, Eye,
Package,
Plus, Plus,
RefreshCw,
Save, Save,
Server,
Settings,
Shield,
Trash2,
Users,
X, X,
AlertTriangle, } from "lucide-react";
RefreshCw import React, { useEffect, useState } from "react";
} from 'lucide-react' import { useAuth } from "../contexts/AuthContext";
import { permissionsAPI } from '../utils/api' import { permissionsAPI } from "../utils/api";
import { useAuth } from '../contexts/AuthContext'
const Permissions = () => { const Permissions = () => {
const [editingRole, setEditingRole] = useState(null) const [editingRole, setEditingRole] = useState(null);
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient() const queryClient = useQueryClient();
const { refreshPermissions } = useAuth() const { refreshPermissions } = useAuth();
// Fetch all role permissions // Fetch all role permissions
const { data: roles, isLoading, error } = useQuery({ const {
queryKey: ['rolePermissions'], data: roles,
queryFn: () => permissionsAPI.getRoles().then(res => res.data) isLoading,
}) error,
} = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update role permissions mutation // Update role permissions mutation
const updateRoleMutation = useMutation({ const updateRoleMutation = useMutation({
mutationFn: ({ role, permissions }) => permissionsAPI.updateRole(role, permissions), mutationFn: ({ role, permissions }) =>
permissionsAPI.updateRole(role, permissions),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(['rolePermissions']) queryClient.invalidateQueries(["rolePermissions"]);
setEditingRole(null) setEditingRole(null);
// Refresh user permissions to apply changes immediately // Refresh user permissions to apply changes immediately
refreshPermissions() refreshPermissions();
} },
}) });
// Delete role mutation // Delete role mutation
const deleteRoleMutation = useMutation({ const deleteRoleMutation = useMutation({
mutationFn: (role) => permissionsAPI.deleteRole(role), mutationFn: (role) => permissionsAPI.deleteRole(role),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(['rolePermissions']) queryClient.invalidateQueries(["rolePermissions"]);
} },
}) });
const handleSavePermissions = async (role, permissions) => { const handleSavePermissions = async (role, permissions) => {
try { try {
await updateRoleMutation.mutateAsync({ role, permissions }) await updateRoleMutation.mutateAsync({ role, permissions });
} catch (error) { } catch (error) {
console.error('Failed to update permissions:', error) console.error("Failed to update permissions:", error);
}
} }
};
const handleDeleteRole = async (role) => { 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 { try {
await deleteRoleMutation.mutateAsync(role) await deleteRoleMutation.mutateAsync(role);
} catch (error) { } catch (error) {
console.error('Failed to delete role:', error) console.error("Failed to delete role:", error);
}
} }
} }
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <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 className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div> </div>
) );
} }
if (error) { if (error) {
@@ -83,12 +92,14 @@ const Permissions = () => {
<div className="flex"> <div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" /> <AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3"> <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> <p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
@@ -115,7 +126,9 @@ const Permissions = () => {
{/* Roles List */} {/* Roles List */}
<div className="space-y-4"> <div className="space-y-4">
{roles && Array.isArray(roles) && roles.map((role) => ( {roles &&
Array.isArray(roles) &&
roles.map((role) => (
<RolePermissionsCard <RolePermissionsCard
key={role.id} key={role.id}
role={role} role={role}
@@ -133,48 +146,105 @@ const Permissions = () => {
isOpen={showAddModal} isOpen={showAddModal}
onClose={() => setShowAddModal(false)} onClose={() => setShowAddModal(false)}
onSuccess={() => { onSuccess={() => {
queryClient.invalidateQueries(['rolePermissions']) queryClient.invalidateQueries(["rolePermissions"]);
setShowAddModal(false) setShowAddModal(false);
}} }}
/> />
</div> </div>
) );
} };
// Role Permissions Card Component // Role Permissions Card Component
const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => { const RolePermissionsCard = ({
const [permissions, setPermissions] = useState(role) role,
isEditing,
onEdit,
onCancel,
onSave,
onDelete,
}) => {
const [permissions, setPermissions] = useState(role);
// Sync permissions state with role prop when it changes // Sync permissions state with role prop when it changes
useEffect(() => { useEffect(() => {
setPermissions(role) setPermissions(role);
}, [role]) }, [role]);
const permissionFields = [ 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_view_dashboard",
{ key: 'can_manage_hosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' }, label: "View Dashboard",
{ key: 'can_view_packages', label: 'View Packages', icon: Package, description: 'See package information' }, icon: BarChart3,
{ key: 'can_manage_packages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' }, description: "Access to the main dashboard",
{ 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_view_hosts",
{ key: 'can_export_data', label: 'Export Data', icon: Download, description: 'Download data and reports' }, label: "View Hosts",
{ key: 'can_manage_settings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' } 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) => { const handlePermissionChange = (key, value) => {
setPermissions(prev => ({ setPermissions((prev) => ({
...prev, ...prev,
[key]: value [key]: value,
})) }));
} };
const handleSave = () => { 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 ( return (
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg"> <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 justify-between">
<div className="flex items-center"> <div className="flex items-center">
<Shield className="h-5 w-5 text-primary-600 mr-3" /> <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 && ( {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"> <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 Built-in Role
@@ -235,8 +307,8 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
<div className="px-6 py-4"> <div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{permissionFields.map((field) => { {permissionFields.map((field) => {
const Icon = field.icon const Icon = field.icon;
const isChecked = permissions[field.key] const isChecked = permissions[field.key];
return ( return (
<div key={field.key} className="flex items-start"> <div key={field.key} className="flex items-start">
@@ -244,8 +316,13 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
<input <input
type="checkbox" type="checkbox"
checked={isChecked} checked={isChecked}
onChange={(e) => handlePermissionChange(field.key, e.target.checked)} onChange={(e) =>
disabled={!isEditing || (isBuiltInRole && field.key === 'can_manage_users')} 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" className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
/> />
</div> </div>
@@ -261,18 +338,18 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
</p> </p>
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
</div> </div>
</div> </div>
) );
} };
// Add Role Modal Component // Add Role Modal Component
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => { const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
role: '', role: "",
can_view_dashboard: true, can_view_dashboard: true,
can_view_hosts: true, can_view_hosts: true,
can_manage_hosts: false, can_manage_hosts: false,
@@ -282,40 +359,42 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
can_manage_users: false, can_manage_users: false,
can_view_reports: true, can_view_reports: true,
can_export_data: false, can_export_data: false,
can_manage_settings: false can_manage_settings: false,
}) });
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError("");
try { try {
await permissionsAPI.updateRole(formData.role, formData) await permissionsAPI.updateRole(formData.role, formData);
onSuccess() onSuccess();
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to create role') setError(err.response?.data?.error || "Failed to create role");
} finally { } finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value, type, checked } = e.target const { name, value, type, checked } = e.target;
setFormData({ setFormData({
...formData, ...formData,
[name]: type === 'checkbox' ? checked : value [name]: type === "checkbox" ? checked : value,
}) });
} };
if (!isOpen) return null if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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"> <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"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <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" 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" 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>
<div className="space-y-3"> <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_dashboard", label: "View Dashboard" },
{ key: 'can_view_hosts', label: 'View Hosts' }, { key: "can_view_hosts", label: "View Hosts" },
{ key: 'can_manage_hosts', label: 'Manage Hosts' }, { key: "can_manage_hosts", label: "Manage Hosts" },
{ key: 'can_view_packages', label: 'View Packages' }, { key: "can_view_packages", label: "View Packages" },
{ key: 'can_manage_packages', label: 'Manage Packages' }, { key: "can_manage_packages", label: "Manage Packages" },
{ key: 'can_view_users', label: 'View Users' }, { key: "can_view_users", label: "View Users" },
{ key: 'can_manage_users', label: 'Manage Users' }, { key: "can_manage_users", label: "Manage Users" },
{ key: 'can_view_reports', label: 'View Reports' }, { key: "can_view_reports", label: "View Reports" },
{ key: 'can_export_data', label: 'Export Data' }, { key: "can_export_data", label: "Export Data" },
{ key: 'can_manage_settings', label: 'Manage Settings' } { key: "can_manage_settings", label: "Manage Settings" },
].map((permission) => ( ].map((permission) => (
<div key={permission.key} className="flex items-center"> <div key={permission.key} className="flex items-center">
<input <input
@@ -365,7 +448,9 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
{error && ( {error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3"> <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> </div>
)} )}
@@ -382,13 +467,13 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
disabled={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" 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> </button>
</div> </div>
</form> </form>
</div> </div>
</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 { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { import {
AlertTriangle,
ArrowDown,
ArrowUp,
ArrowUpDown,
Check,
Columns,
Database,
Eye,
Globe,
GripVertical,
Lock,
RefreshCw,
Search,
Server, Server,
Shield, Shield,
ShieldCheck, ShieldCheck,
AlertTriangle,
Users,
Globe,
Lock,
Unlock, Unlock,
Database, Users,
Eye,
Search,
Columns,
ArrowUpDown,
ArrowUp,
ArrowDown,
X, X,
GripVertical, } from "lucide-react";
Check, import React, { useMemo, useState } from "react";
RefreshCw import { Link } from "react-router-dom";
} from 'lucide-react'; import { repositoryAPI } from "../utils/api";
import { repositoryAPI } from '../utils/api';
const Repositories = () => { const Repositories = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState("");
const [filterType, setFilterType] = useState('all'); // all, secure, insecure const [filterType, setFilterType] = useState("all"); // all, secure, insecure
const [filterStatus, setFilterStatus] = useState('all'); // all, active, inactive const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive
const [sortField, setSortField] = useState('name'); const [sortField, setSortField] = useState("name");
const [sortDirection, setSortDirection] = useState('asc'); const [sortDirection, setSortDirection] = useState("asc");
const [showColumnSettings, setShowColumnSettings] = useState(false); const [showColumnSettings, setShowColumnSettings] = useState(false);
// Column configuration // Column configuration
const [columnConfig, setColumnConfig] = useState(() => { const [columnConfig, setColumnConfig] = useState(() => {
const defaultConfig = [ const defaultConfig = [
{ id: 'name', label: 'Repository', visible: true, order: 0 }, { id: "name", label: "Repository", visible: true, order: 0 },
{ id: 'url', label: 'URL', visible: true, order: 1 }, { id: "url", label: "URL", visible: true, order: 1 },
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 }, { id: "distribution", label: "Distribution", visible: true, order: 2 },
{ id: 'security', label: 'Security', visible: true, order: 3 }, { id: "security", label: "Security", visible: true, order: 3 },
{ id: 'status', label: 'Status', visible: true, order: 4 }, { id: "status", label: "Status", visible: true, order: 4 },
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 }, { id: "hostCount", label: "Hosts", visible: true, order: 5 },
{ id: 'actions', label: 'Actions', visible: true, order: 6 } { id: "actions", label: "Actions", visible: true, order: 6 },
]; ];
const saved = localStorage.getItem('repositories-column-config'); const saved = localStorage.getItem("repositories-column-config");
if (saved) { if (saved) {
try { try {
return JSON.parse(saved); return JSON.parse(saved);
} catch (e) { } catch (e) {
console.error('Failed to parse saved column config:', e); console.error("Failed to parse saved column config:", e);
} }
} }
return defaultConfig; return defaultConfig;
@@ -57,92 +57,114 @@ const Repositories = () => {
const updateColumnConfig = (newConfig) => { const updateColumnConfig = (newConfig) => {
setColumnConfig(newConfig); setColumnConfig(newConfig);
localStorage.setItem('repositories-column-config', JSON.stringify(newConfig)); localStorage.setItem(
"repositories-column-config",
JSON.stringify(newConfig),
);
}; };
// Fetch repositories // Fetch repositories
const { data: repositories = [], isLoading, error, refetch, isFetching } = useQuery({ const {
queryKey: ['repositories'], data: repositories = [],
queryFn: () => repositoryAPI.list().then(res => res.data) isLoading,
error,
refetch,
isFetching,
} = useQuery({
queryKey: ["repositories"],
queryFn: () => repositoryAPI.list().then((res) => res.data),
}); });
// Fetch repository statistics // Fetch repository statistics
const { data: stats } = useQuery({ const { data: stats } = useQuery({
queryKey: ['repository-stats'], queryKey: ["repository-stats"],
queryFn: () => repositoryAPI.getStats().then(res => res.data) queryFn: () => repositoryAPI.getStats().then((res) => res.data),
}); });
// Get visible columns in order // Get visible columns in order
const visibleColumns = columnConfig const visibleColumns = columnConfig
.filter(col => col.visible) .filter((col) => col.visible)
.sort((a, b) => a.order - b.order); .sort((a, b) => a.order - b.order);
// Sorting functions // Sorting functions
const handleSort = (field) => { const handleSort = (field) => {
if (sortField === field) { if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else { } else {
setSortField(field); setSortField(field);
setSortDirection('asc'); setSortDirection("asc");
} }
}; };
const getSortIcon = (field) => { const getSortIcon = (field) => {
if (sortField !== field) return <ArrowUpDown 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" /> return sortDirection === "asc" ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
);
}; };
// Column management functions // Column management functions
const toggleColumnVisibility = (columnId) => { const toggleColumnVisibility = (columnId) => {
const newConfig = columnConfig.map(col => const newConfig = columnConfig.map((col) =>
col.id === columnId ? { ...col, visible: !col.visible } : col col.id === columnId ? { ...col, visible: !col.visible } : col,
) );
updateColumnConfig(newConfig) updateColumnConfig(newConfig);
}; };
const reorderColumns = (fromIndex, toIndex) => { const reorderColumns = (fromIndex, toIndex) => {
const newConfig = [...columnConfig] const newConfig = [...columnConfig];
const [movedColumn] = newConfig.splice(fromIndex, 1) const [movedColumn] = newConfig.splice(fromIndex, 1);
newConfig.splice(toIndex, 0, movedColumn) newConfig.splice(toIndex, 0, movedColumn);
// Update order values // Update order values
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index })) const updatedConfig = newConfig.map((col, index) => ({
updateColumnConfig(updatedConfig) ...col,
order: index,
}));
updateColumnConfig(updatedConfig);
}; };
const resetColumns = () => { const resetColumns = () => {
const defaultConfig = [ const defaultConfig = [
{ id: 'name', label: 'Repository', visible: true, order: 0 }, { id: "name", label: "Repository", visible: true, order: 0 },
{ id: 'url', label: 'URL', visible: true, order: 1 }, { id: "url", label: "URL", visible: true, order: 1 },
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 }, { id: "distribution", label: "Distribution", visible: true, order: 2 },
{ id: 'security', label: 'Security', visible: true, order: 3 }, { id: "security", label: "Security", visible: true, order: 3 },
{ id: 'status', label: 'Status', visible: true, order: 4 }, { id: "status", label: "Status", visible: true, order: 4 },
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 }, { id: "hostCount", label: "Hosts", visible: true, order: 5 },
{ id: 'actions', label: 'Actions', visible: true, order: 6 } { id: "actions", label: "Actions", visible: true, order: 6 },
] ];
updateColumnConfig(defaultConfig) updateColumnConfig(defaultConfig);
}; };
// Filter and sort repositories // Filter and sort repositories
const filteredAndSortedRepositories = useMemo(() => { const filteredAndSortedRepositories = useMemo(() => {
if (!repositories) return [] if (!repositories) return [];
// Filter repositories // Filter repositories
const filtered = repositories.filter(repo => { const filtered = repositories.filter((repo) => {
const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) || const matchesSearch =
repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) || repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase()); repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
// Check security based on URL if isSecure property doesn't exist // 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' || const matchesType =
(filterType === 'secure' && isSecure) || filterType === "all" ||
(filterType === 'insecure' && !isSecure); (filterType === "secure" && isSecure) ||
(filterType === "insecure" && !isSecure);
const matchesStatus = filterStatus === 'all' || const matchesStatus =
(filterStatus === 'active' && repo.is_active === true) || filterStatus === "all" ||
(filterStatus === 'inactive' && repo.is_active === false); (filterStatus === "active" && repo.is_active === true) ||
(filterStatus === "inactive" && repo.is_active === false);
return matchesSearch && matchesType && matchesStatus; return matchesSearch && matchesType && matchesStatus;
}); });
@@ -153,26 +175,33 @@ const Repositories = () => {
let bValue = b[sortField]; let bValue = b[sortField];
// Handle special cases // Handle special cases
if (sortField === 'security') { if (sortField === "security") {
aValue = a.isSecure ? 'Secure' : 'Insecure'; aValue = a.isSecure ? "Secure" : "Insecure";
bValue = b.isSecure ? 'Secure' : 'Insecure'; bValue = b.isSecure ? "Secure" : "Insecure";
} else if (sortField === 'status') { } else if (sortField === "status") {
aValue = a.is_active ? 'Active' : 'Inactive'; aValue = a.is_active ? "Active" : "Inactive";
bValue = b.is_active ? 'Active' : 'Inactive'; bValue = b.is_active ? "Active" : "Inactive";
} }
if (typeof aValue === 'string') { if (typeof aValue === "string") {
aValue = aValue.toLowerCase(); aValue = aValue.toLowerCase();
bValue = bValue.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 0;
}); });
return sorted; return sorted;
}, [repositories, searchTerm, filterType, filterStatus, sortField, sortDirection]); }, [
repositories,
searchTerm,
filterType,
filterStatus,
sortField,
sortDirection,
]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -200,7 +229,9 @@ const Repositories = () => {
{/* Page Header */} {/* Page Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <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"> <p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Manage and monitor your package repositories Manage and monitor your package repositories
</p> </p>
@@ -212,8 +243,10 @@ const Repositories = () => {
className="btn-outline flex items-center gap-2" className="btn-outline flex items-center gap-2"
title="Refresh repositories data" title="Refresh repositories data"
> >
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} /> <RefreshCw
{isFetching ? 'Refreshing...' : 'Refresh'} className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
{isFetching ? "Refreshing..." : "Refresh"}
</button> </button>
</div> </div>
</div> </div>
@@ -224,8 +257,12 @@ const Repositories = () => {
<div className="flex items-center"> <div className="flex items-center">
<Database className="h-5 w-5 text-primary-600 mr-2" /> <Database className="h-5 w-5 text-primary-600 mr-2" />
<div> <div>
<p className="text-sm text-secondary-500 dark:text-white">Total Repositories</p> <p className="text-sm text-secondary-500 dark:text-white">
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.totalRepositories || 0}</p> Total Repositories
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats?.totalRepositories || 0}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -234,8 +271,12 @@ const Repositories = () => {
<div className="flex items-center"> <div className="flex items-center">
<Server className="h-5 w-5 text-success-600 mr-2" /> <Server className="h-5 w-5 text-success-600 mr-2" />
<div> <div>
<p className="text-sm text-secondary-500 dark:text-white">Active Repositories</p> <p className="text-sm text-secondary-500 dark:text-white">
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.activeRepositories || 0}</p> Active Repositories
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats?.activeRepositories || 0}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -244,8 +285,12 @@ const Repositories = () => {
<div className="flex items-center"> <div className="flex items-center">
<Shield className="h-5 w-5 text-warning-600 mr-2" /> <Shield className="h-5 w-5 text-warning-600 mr-2" />
<div> <div>
<p className="text-sm text-secondary-500 dark:text-white">Secure (HTTPS)</p> <p className="text-sm text-secondary-500 dark:text-white">
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.secureRepositories || 0}</p> Secure (HTTPS)
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats?.secureRepositories || 0}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -254,8 +299,12 @@ const Repositories = () => {
<div className="flex items-center"> <div className="flex items-center">
<ShieldCheck className="h-5 w-5 text-danger-600 mr-2" /> <ShieldCheck className="h-5 w-5 text-danger-600 mr-2" />
<div> <div>
<p className="text-sm text-secondary-500 dark:text-white">Security Score</p> <p className="text-sm text-secondary-500 dark:text-white">
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.securityPercentage || 0}%</p> Security Score
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats?.securityPercentage || 0}%
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -329,7 +378,9 @@ const Repositories = () => {
<div className="text-center py-8"> <div className="text-center py-8">
<Database className="h-12 w-12 text-secondary-400 mx-auto mb-4" /> <Database className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300"> <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> </p>
{repositories?.length === 0 && ( {repositories?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2"> <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"> <thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
<tr> <tr>
{visibleColumns.map((column) => ( {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 <button
onClick={() => handleSort(column.id)} onClick={() => handleSort(column.id)}
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors" className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
@@ -357,9 +411,15 @@ const Repositories = () => {
</thead> </thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600"> <tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredAndSortedRepositories.map((repo) => ( {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) => ( {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)} {renderCellContent(column, repo)}
</td> </td>
))} ))}
@@ -389,7 +449,7 @@ const Repositories = () => {
// Render cell content based on column type // Render cell content based on column type
function renderCellContent(column, repo) { function renderCellContent(column, repo) {
switch (column.id) { switch (column.id) {
case 'name': case "name":
return ( return (
<div className="flex items-center"> <div className="flex items-center">
<Database className="h-5 w-5 text-secondary-400 mr-3" /> <Database className="h-5 w-5 text-secondary-400 mr-3" />
@@ -399,21 +459,27 @@ const Repositories = () => {
</div> </div>
</div> </div>
</div> </div>
) );
case 'url': case "url":
return ( 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} {repo.url}
</div> </div>
) );
case 'distribution': case "distribution":
return ( return (
<div className="text-sm text-secondary-900 dark:text-white"> <div className="text-sm text-secondary-900 dark:text-white">
{repo.distribution} {repo.distribution}
</div> </div>
) );
case 'security': case "security": {
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://'); const isSecure =
repo.isSecure !== undefined
? repo.isSecure
: repo.url.startsWith("https://");
return ( return (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
{isSecure ? ( {isSecure ? (
@@ -428,25 +494,28 @@ const Repositories = () => {
</div> </div>
)} )}
</div> </div>
) );
case 'status': }
case "status":
return ( 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 repo.is_active
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300' ? "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' : "bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300"
}`}> }`}
{repo.is_active ? 'Active' : 'Inactive'} >
{repo.is_active ? "Active" : "Inactive"}
</span> </span>
) );
case 'hostCount': case "hostCount":
return ( return (
<div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white"> <div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
<span>{repo.host_count}</span> <span>{repo.host_count}</span>
</div> </div>
) );
case 'actions': case "actions":
return ( return (
<Link <Link
to={`/repositories/${repo.id}`} to={`/repositories/${repo.id}`}
@@ -455,41 +524,52 @@ const Repositories = () => {
View View
<Eye className="h-3 w-3" /> <Eye className="h-3 w-3" />
</Link> </Link>
) );
default: default:
return null return null;
} }
} }
}; };
// Column Settings Modal Component // Column Settings Modal Component
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => { const ColumnSettingsModal = ({
const [draggedIndex, setDraggedIndex] = useState(null) columnConfig,
onClose,
onToggleVisibility,
onReorder,
onReset,
}) => {
const [draggedIndex, setDraggedIndex] = useState(null);
const handleDragStart = (e, index) => { const handleDragStart = (e, index) => {
setDraggedIndex(index) setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = "move";
} };
const handleDragOver = (e) => { const handleDragOver = (e) => {
e.preventDefault() e.preventDefault();
e.dataTransfer.dropEffect = 'move' e.dataTransfer.dropEffect = "move";
} };
const handleDrop = (e, dropIndex) => { const handleDrop = (e, dropIndex) => {
e.preventDefault() e.preventDefault();
if (draggedIndex !== null && draggedIndex !== dropIndex) { if (draggedIndex !== null && draggedIndex !== dropIndex) {
onReorder(draggedIndex, dropIndex) onReorder(draggedIndex, dropIndex);
}
setDraggedIndex(null)
} }
setDraggedIndex(null);
};
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Column Settings</h3> <h3 className="text-lg font-medium text-secondary-900 dark:text-white">
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"> 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" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
@@ -514,8 +594,8 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
onClick={() => onToggleVisibility(column.id)} onClick={() => onToggleVisibility(column.id)}
className={`w-4 h-4 rounded border-2 flex items-center justify-center ${ className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
column.visible column.visible
? 'bg-primary-600 border-primary-600' ? "bg-primary-600 border-primary-600"
: 'bg-white dark:bg-secondary-800 border-secondary-300 dark:border-secondary-600' : "bg-white dark:bg-secondary-800 border-secondary-300 dark:border-secondary-600"
}`} }`}
> >
{column.visible && <Check className="h-3 w-3 text-white" />} {column.visible && <Check className="h-3 w-3 text-white" />}
@@ -540,7 +620,7 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
</div> </div>
</div> </div>
</div> </div>
) );
}; };
export default Repositories; export default Repositories;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,83 +1,103 @@
import React, { useState } from 'react' import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import {
import { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle, Key } from 'lucide-react' Calendar,
import { adminUsersAPI, permissionsAPI } from '../utils/api' CheckCircle,
import { useAuth } from '../contexts/AuthContext' 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 Users = () => {
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false);
const [editingUser, setEditingUser] = useState(null) const [editingUser, setEditingUser] = useState(null);
const [resetPasswordUser, setResetPasswordUser] = useState(null) const [resetPasswordUser, setResetPasswordUser] = useState(null);
const queryClient = useQueryClient() const queryClient = useQueryClient();
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth();
// Fetch users // Fetch users
const { data: users, isLoading, error } = useQuery({ const {
queryKey: ['users'], data: users,
queryFn: () => adminUsersAPI.list().then(res => res.data) isLoading,
}) error,
} = useQuery({
queryKey: ["users"],
queryFn: () => adminUsersAPI.list().then((res) => res.data),
});
// Fetch available roles // Fetch available roles
const { data: roles } = useQuery({ const { data: roles } = useQuery({
queryKey: ['rolePermissions'], queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then(res => res.data) queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
}) });
// Delete user mutation // Delete user mutation
const deleteUserMutation = useMutation({ const deleteUserMutation = useMutation({
mutationFn: adminUsersAPI.delete, mutationFn: adminUsersAPI.delete,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(['users']) queryClient.invalidateQueries(["users"]);
} },
}) });
// Update user mutation // Update user mutation
const updateUserMutation = useMutation({ const updateUserMutation = useMutation({
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data), mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(['users']) queryClient.invalidateQueries(["users"]);
setEditingUser(null) setEditingUser(null);
} },
}) });
// Reset password mutation // Reset password mutation
const resetPasswordMutation = useMutation({ const resetPasswordMutation = useMutation({
mutationFn: ({ userId, newPassword }) => adminUsersAPI.resetPassword(userId, newPassword), mutationFn: ({ userId, newPassword }) =>
adminUsersAPI.resetPassword(userId, newPassword),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(['users']) queryClient.invalidateQueries(["users"]);
setResetPasswordUser(null) setResetPasswordUser(null);
} },
}) });
const handleDeleteUser = async (userId, username) => { 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 { try {
await deleteUserMutation.mutateAsync(userId) await deleteUserMutation.mutateAsync(userId);
} catch (error) { } catch (error) {
console.error('Failed to delete user:', error) console.error("Failed to delete user:", error);
}
} }
} }
};
const handleUserCreated = () => { const handleUserCreated = () => {
queryClient.invalidateQueries(['users']) queryClient.invalidateQueries(["users"]);
setShowAddModal(false) setShowAddModal(false);
} };
const handleEditUser = (user) => { const handleEditUser = (user) => {
setEditingUser(user) setEditingUser(user);
} };
const handleResetPassword = (user) => { const handleResetPassword = (user) => {
setResetPasswordUser(user) setResetPasswordUser(user);
} };
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <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 className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div> </div>
) );
} }
if (error) { if (error) {
@@ -86,12 +106,14 @@ const Users = () => {
<div className="flex"> <div className="flex">
<XCircle className="h-5 w-5 text-danger-400" /> <XCircle className="h-5 w-5 text-danger-400" />
<div className="ml-3"> <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> <p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
@@ -122,23 +144,28 @@ const Users = () => {
</div> </div>
<div className="ml-4"> <div className="ml-4">
<div className="flex items-center"> <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 && ( {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"> <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 You
</span> </span>
)} )}
<span className={`ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ <span
user.role === 'admin' 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' user.role === "admin"
: user.role === 'host_manager' ? "bg-primary-100 text-primary-800"
? 'bg-green-100 text-green-800' : user.role === "host_manager"
: user.role === 'readonly' ? "bg-green-100 text-green-800"
? 'bg-yellow-100 text-yellow-800' : user.role === "readonly"
: 'bg-secondary-100 text-secondary-800' ? "bg-yellow-100 text-yellow-800"
}`}> : "bg-secondary-100 text-secondary-800"
}`}
>
<Shield className="h-3 w-3 mr-1" /> <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> </span>
{user.is_active ? ( {user.is_active ? (
<CheckCircle className="ml-2 h-4 w-4 text-green-500" /> <CheckCircle className="ml-2 h-4 w-4 text-green-500" />
@@ -152,11 +179,13 @@ const Users = () => {
</div> </div>
<div className="flex items-center mt-1 text-sm text-secondary-500 dark:text-secondary-300"> <div className="flex items-center mt-1 text-sm text-secondary-500 dark:text-secondary-300">
<Calendar className="h-4 w-4 mr-1" /> <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 && ( {user.last_login && (
<> <>
<span className="mx-2"></span> <span className="mx-2"></span>
Last login: {new Date(user.last_login).toLocaleDateString()} Last login:{" "}
{new Date(user.last_login).toLocaleDateString()}
</> </>
)} )}
</div> </div>
@@ -188,13 +217,16 @@ const Users = () => {
title={ title={
user.id === currentUser?.id user.id === currentUser?.id
? "Cannot delete your own account" ? "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" ? "Cannot delete the last admin user"
: "Delete user" : "Delete user"
} }
disabled={ disabled={
user.id === currentUser?.id || 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" /> <Trash2 className="h-4 w-4" />
@@ -207,7 +239,9 @@ const Users = () => {
<li> <li>
<div className="px-4 py-8 text-center"> <div className="px-4 py-8 text-center">
<User className="h-12 w-12 text-secondary-400 mx-auto mb-4" /> <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"> <p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Click "Add User" to create the first user Click "Add User" to create the first user
</p> </p>
@@ -247,55 +281,61 @@ const Users = () => {
/> />
)} )}
</div> </div>
) );
} };
// Add User Modal Component // Add User Modal Component
const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => { const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: '', username: "",
email: '', email: "",
password: '', password: "",
first_name: '', first_name: "",
last_name: '', last_name: "",
role: 'user' role: "user",
}) });
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError("");
try { try {
// Only send role if roles are available from API // 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) { if (roles && Array.isArray(roles) && roles.length > 0) {
payload.role = formData.role payload.role = formData.role;
} }
const response = await adminUsersAPI.create(payload) const response = await adminUsersAPI.create(payload);
onUserCreated() onUserCreated();
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to create user') setError(err.response?.data?.error || "Failed to create user");
} finally { } finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
const handleInputChange = (e) => { const handleInputChange = (e) => {
setFormData({ setFormData({
...formData, ...formData,
[e.target.name]: e.target.value [e.target.name]: e.target.value,
}) });
} };
if (!isOpen) return null if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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="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"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
@@ -366,7 +406,9 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
onChange={handleInputChange} 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" 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>
<div> <div>
@@ -382,7 +424,8 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
{roles && Array.isArray(roles) && roles.length > 0 ? ( {roles && Array.isArray(roles) && roles.length > 0 ? (
roles.map((role) => ( roles.map((role) => (
<option key={role.role} value={role.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> </option>
)) ))
) : ( ) : (
@@ -396,7 +439,9 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
{error && ( {error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3"> <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> </div>
)} )}
@@ -413,57 +458,59 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
disabled={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" 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> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
) );
} };
// Edit User Modal Component // Edit User Modal Component
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => { const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: user?.username || '', username: user?.username || "",
email: user?.email || '', email: user?.email || "",
first_name: user?.first_name || '', first_name: user?.first_name || "",
last_name: user?.last_name || '', last_name: user?.last_name || "",
role: user?.role || 'user', role: user?.role || "user",
is_active: user?.is_active ?? true is_active: user?.is_active ?? true,
}) });
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError("");
try { try {
await adminUsersAPI.update(user.id, formData) await adminUsersAPI.update(user.id, formData);
onUserUpdated() onUserUpdated();
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to update user') setError(err.response?.data?.error || "Failed to update user");
} finally { } finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value, type, checked } = e.target const { name, value, type, checked } = e.target;
setFormData({ setFormData({
...formData, ...formData,
[name]: type === 'checkbox' ? checked : value [name]: type === "checkbox" ? checked : value,
}) });
} };
if (!isOpen || !user) return null if (!isOpen || !user) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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="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"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
@@ -534,7 +581,8 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
{roles && Array.isArray(roles) ? ( {roles && Array.isArray(roles) ? (
roles.map((role) => ( roles.map((role) => (
<option key={role.role} value={role.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> </option>
)) ))
) : ( ) : (
@@ -561,7 +609,9 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
{error && ( {error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3"> <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> </div>
)} )}
@@ -578,54 +628,60 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
disabled={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" 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> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
) );
} };
// Reset Password Modal Component // Reset Password Modal Component
const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading }) => { const ResetPasswordModal = ({
const [newPassword, setNewPassword] = useState('') user,
const [confirmPassword, setConfirmPassword] = useState('') isOpen,
const [error, setError] = useState('') onClose,
onPasswordReset,
isLoading,
}) => {
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault();
setError('') setError("");
// Validate passwords // Validate passwords
if (newPassword.length < 6) { if (newPassword.length < 6) {
setError('Password must be at least 6 characters long') setError("Password must be at least 6 characters long");
return return;
} }
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError('Passwords do not match') setError("Passwords do not match");
return return;
} }
try { try {
await onPasswordReset({ userId: user.id, newPassword }) await onPasswordReset({ userId: user.id, newPassword });
// Reset form on success // Reset form on success
setNewPassword('') setNewPassword("");
setConfirmPassword('') setConfirmPassword("");
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to reset password') setError(err.response?.data?.error || "Failed to reset password");
}
} }
};
const handleClose = () => { const handleClose = () => {
setNewPassword('') setNewPassword("");
setConfirmPassword('') setConfirmPassword("");
setError('') setError("");
onClose() onClose();
} };
if (!isOpen) return null if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <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 Password Reset Warning
</h3> </h3>
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300"> <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> </div>
</div> </div>
@@ -682,7 +741,9 @@ const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading
{error && ( {error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3"> <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> </div>
)} )}
@@ -699,14 +760,16 @@ const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading
disabled={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" 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 && (
{isLoading ? 'Resetting...' : 'Reset Password'} <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
)}
{isLoading ? "Resetting..." : "Reset Password"}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</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 // Create axios instance with default config
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
timeout: 10000, timeout: 10000,
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}) });
// Request interceptor // Request interceptor
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
// Add auth token if available // Add auth token if available
const token = localStorage.getItem('token') const token = localStorage.getItem("token");
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`;
} }
return config return config;
}, },
(error) => { (error) => {
return Promise.reject(error) return Promise.reject(error);
} },
) );
// Response interceptor // Response interceptor
api.interceptors.response.use( api.interceptors.response.use(
@@ -32,200 +32,235 @@ api.interceptors.response.use(
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Don't redirect if we're on the login page or if it's a TFA verification error // Don't redirect if we're on the login page or if it's a TFA verification error
const currentPath = window.location.pathname const currentPath = window.location.pathname;
const isTfaError = error.config?.url?.includes('/verify-tfa') const isTfaError = error.config?.url?.includes("/verify-tfa");
if (currentPath !== '/login' && !isTfaError) { if (currentPath !== "/login" && !isTfaError) {
// Handle unauthorized // Handle unauthorized
localStorage.removeItem('token') localStorage.removeItem("token");
localStorage.removeItem('user') localStorage.removeItem("user");
localStorage.removeItem('permissions') localStorage.removeItem("permissions");
window.location.href = '/login' window.location.href = "/login";
} }
} }
return Promise.reject(error) return Promise.reject(error);
} },
) );
// Dashboard API // Dashboard API
export const dashboardAPI = { export const dashboardAPI = {
getStats: () => api.get('/dashboard/stats'), getStats: () => api.get("/dashboard/stats"),
getHosts: () => api.get('/dashboard/hosts'), getHosts: () => api.get("/dashboard/hosts"),
getPackages: () => api.get('/dashboard/packages'), getPackages: () => api.get("/dashboard/packages"),
getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`), getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
getRecentUsers: () => api.get('/dashboard/recent-users'), getRecentUsers: () => api.get("/dashboard/recent-users"),
getRecentCollection: () => api.get('/dashboard/recent-collection') getRecentCollection: () => api.get("/dashboard/recent-collection"),
} };
// Admin Hosts API (for management interface) // Admin Hosts API (for management interface)
export const adminHostsAPI = { export const adminHostsAPI = {
create: (data) => api.post('/hosts/create', data), create: (data) => api.post("/hosts/create", data),
list: () => api.get('/hosts/admin/list'), list: () => api.get("/hosts/admin/list"),
delete: (hostId) => api.delete(`/hosts/${hostId}`), delete: (hostId) => api.delete(`/hosts/${hostId}`),
deleteBulk: (hostIds) => api.delete('/hosts/bulk', { data: { hostIds } }), deleteBulk: (hostIds) => api.delete("/hosts/bulk", { data: { hostIds } }),
regenerateCredentials: (hostId) => api.post(`/hosts/${hostId}/regenerate-credentials`), regenerateCredentials: (hostId) =>
updateGroup: (hostId, hostGroupId) => api.put(`/hosts/${hostId}/group`, { hostGroupId }), api.post(`/hosts/${hostId}/regenerate-credentials`),
bulkUpdateGroup: (hostIds, hostGroupId) => api.put('/hosts/bulk/group', { hostIds, hostGroupId }), updateGroup: (hostId, hostGroupId) =>
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }), api.put(`/hosts/${hostId}/group`, { hostGroupId }),
updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendly_name: friendlyName }) 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 // Host Groups API
export const hostGroupsAPI = { export const hostGroupsAPI = {
list: () => api.get('/host-groups'), list: () => api.get("/host-groups"),
get: (id) => api.get(`/host-groups/${id}`), 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), update: (id, data) => api.put(`/host-groups/${id}`, data),
delete: (id) => api.delete(`/host-groups/${id}`), delete: (id) => api.delete(`/host-groups/${id}`),
getHosts: (id) => api.get(`/host-groups/${id}/hosts`), getHosts: (id) => api.get(`/host-groups/${id}/hosts`),
} };
// Admin Users API (for user management) // Admin Users API (for user management)
export const adminUsersAPI = { export const adminUsersAPI = {
list: () => api.get('/auth/admin/users'), list: () => api.get("/auth/admin/users"),
create: (userData) => api.post('/auth/admin/users', userData), create: (userData) => api.post("/auth/admin/users", userData),
update: (userId, userData) => api.put(`/auth/admin/users/${userId}`, userData), update: (userId, userData) =>
api.put(`/auth/admin/users/${userId}`, userData),
delete: (userId) => api.delete(`/auth/admin/users/${userId}`), delete: (userId) => api.delete(`/auth/admin/users/${userId}`),
resetPassword: (userId, newPassword) => api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword }) resetPassword: (userId, newPassword) =>
} api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword }),
};
// Permissions API (for role management) // Permissions API (for role management)
export const permissionsAPI = { export const permissionsAPI = {
getRoles: () => api.get('/permissions/roles'), getRoles: () => api.get("/permissions/roles"),
getRole: (role) => api.get(`/permissions/roles/${role}`), 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}`), deleteRole: (role) => api.delete(`/permissions/roles/${role}`),
getUserPermissions: () => api.get('/permissions/user-permissions') getUserPermissions: () => api.get("/permissions/user-permissions"),
} };
// Settings API // Settings API
export const settingsAPI = { export const settingsAPI = {
get: () => api.get('/settings'), get: () => api.get("/settings"),
update: (settings) => api.put('/settings', settings), update: (settings) => api.put("/settings", settings),
getServerUrl: () => api.get('/settings/server-url') getServerUrl: () => api.get("/settings/server-url"),
} };
// Agent Version API // Agent Version API
export const agentVersionAPI = { export const agentVersionAPI = {
list: () => api.get('/hosts/agent/versions'), list: () => api.get("/hosts/agent/versions"),
create: (data) => api.post('/hosts/agent/versions', data), create: (data) => api.post("/hosts/agent/versions", data),
update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data), update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data),
delete: (id) => api.delete(`/hosts/agent/versions/${id}`), delete: (id) => api.delete(`/hosts/agent/versions/${id}`),
setCurrent: (id) => api.patch(`/hosts/agent/versions/${id}/current`), setCurrent: (id) => api.patch(`/hosts/agent/versions/${id}/current`),
setDefault: (id) => api.patch(`/hosts/agent/versions/${id}/default`), 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 // Repository API
export const repositoryAPI = { export const repositoryAPI = {
list: () => api.get('/repositories'), list: () => api.get("/repositories"),
getById: (repositoryId) => api.get(`/repositories/${repositoryId}`), getById: (repositoryId) => api.get(`/repositories/${repositoryId}`),
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`), 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) => toggleHostRepository: (hostId, repositoryId, isEnabled) =>
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, { isEnabled }), api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, {
getStats: () => api.get('/repositories/stats/summary'), isEnabled,
cleanupOrphaned: () => api.delete('/repositories/cleanup/orphaned') }),
} getStats: () => api.get("/repositories/stats/summary"),
cleanupOrphaned: () => api.delete("/repositories/cleanup/orphaned"),
};
// Dashboard Preferences API // Dashboard Preferences API
export const dashboardPreferencesAPI = { export const dashboardPreferencesAPI = {
get: () => api.get('/dashboard-preferences'), get: () => api.get("/dashboard-preferences"),
update: (preferences) => api.put('/dashboard-preferences', { preferences }), update: (preferences) => api.put("/dashboard-preferences", { preferences }),
getDefaults: () => api.get('/dashboard-preferences/defaults') getDefaults: () => api.get("/dashboard-preferences/defaults"),
} };
// Hosts API (for agent communication - kept for compatibility) // Hosts API (for agent communication - kept for compatibility)
export const hostsAPI = { export const hostsAPI = {
// Legacy register endpoint (now deprecated) // Legacy register endpoint (now deprecated)
register: (data) => api.post('/hosts/register', data), register: (data) => api.post("/hosts/register", data),
// Updated to use API credentials // Updated to use API credentials
update: (apiId, apiKey, data) => api.post('/hosts/update', data, { update: (apiId, apiKey, data) =>
api.post("/hosts/update", data, {
headers: { headers: {
'X-API-ID': apiId, "X-API-ID": apiId,
'X-API-KEY': apiKey "X-API-KEY": apiKey,
} },
}), }),
getInfo: (apiId, apiKey) => api.get('/hosts/info', { getInfo: (apiId, apiKey) =>
api.get("/hosts/info", {
headers: { headers: {
'X-API-ID': apiId, "X-API-ID": apiId,
'X-API-KEY': apiKey "X-API-KEY": apiKey,
} },
}), }),
ping: (apiId, apiKey) => api.post('/hosts/ping', {}, { ping: (apiId, apiKey) =>
api.post(
"/hosts/ping",
{},
{
headers: { headers: {
'X-API-ID': apiId, "X-API-ID": apiId,
'X-API-KEY': apiKey "X-API-KEY": apiKey,
} },
}), },
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate }) ),
} toggleAutoUpdate: (id, autoUpdate) =>
api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate }),
};
// Packages API // Packages API
export const packagesAPI = { export const packagesAPI = {
getAll: (params = {}) => api.get('/packages', { params }), getAll: (params = {}) => api.get("/packages", { params }),
getById: (packageId) => api.get(`/packages/${packageId}`), getById: (packageId) => api.get(`/packages/${packageId}`),
getCategories: () => api.get('/packages/categories/list'), getCategories: () => api.get("/packages/categories/list"),
getHosts: (packageId, params = {}) => api.get(`/packages/${packageId}/hosts`, { params }), getHosts: (packageId, params = {}) =>
api.get(`/packages/${packageId}/hosts`, { params }),
update: (packageId, data) => api.put(`/packages/${packageId}`, data), 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 // Utility functions
export const formatError = (error) => { export const formatError = (error) => {
if (error.response?.data?.message) { if (error.response?.data?.message) {
return error.response.data.message return error.response.data.message;
} }
if (error.response?.data?.error) { if (error.response?.data?.error) {
return error.response.data.error return error.response.data.error;
} }
if (error.message) { if (error.message) {
return error.message return error.message;
} }
return 'An unexpected error occurred' return "An unexpected error occurred";
} };
export const formatDate = (date) => { export const formatDate = (date) => {
return new Date(date).toLocaleString() return new Date(date).toLocaleString();
} };
// Version API // Version API
export const versionAPI = { export const versionAPI = {
getCurrent: () => api.get('/version/current'), getCurrent: () => api.get("/version/current"),
checkUpdates: () => api.get('/version/check-updates'), checkUpdates: () => api.get("/version/check-updates"),
testSshKey: (data) => api.post('/version/test-ssh-key', data), testSshKey: (data) => api.post("/version/test-ssh-key", data),
} };
// Auth API // Auth API
export const authAPI = { export const authAPI = {
login: (username, password) => api.post('/auth/login', { username, password }), login: (username, password) =>
verifyTfa: (username, token) => api.post('/auth/verify-tfa', { username, token }), api.post("/auth/login", { username, password }),
signup: (username, email, password, firstName, lastName) => api.post('/auth/signup', { username, email, password, firstName, lastName }), 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 // TFA API
export const tfaAPI = { export const tfaAPI = {
setup: () => api.get('/tfa/setup'), setup: () => api.get("/tfa/setup"),
verifySetup: (data) => api.post('/tfa/verify-setup', data), verifySetup: (data) => api.post("/tfa/verify-setup", data),
disable: (data) => api.post('/tfa/disable', data), disable: (data) => api.post("/tfa/disable", data),
status: () => api.get('/tfa/status'), status: () => api.get("/tfa/status"),
regenerateBackupCodes: () => api.post('/tfa/regenerate-backup-codes'), regenerateBackupCodes: () => api.post("/tfa/regenerate-backup-codes"),
verify: (data) => api.post('/tfa/verify', data), verify: (data) => api.post("/tfa/verify", data),
} };
export const formatRelativeTime = (date) => { export const formatRelativeTime = (date) => {
const now = new Date() const now = new Date();
const diff = now - new Date(date) const diff = now - new Date(date);
const seconds = Math.floor(diff / 1000) const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60) const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24);
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago` if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago` if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago` if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
return `${seconds} second${seconds > 1 ? 's' : ''} ago` return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
} };
export default api export default api;

View File

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

View File

@@ -1,85 +1,85 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: [ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
"./index.html", darkMode: "class",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: { primary: {
50: '#eff6ff', 50: "#eff6ff",
100: '#dbeafe', 100: "#dbeafe",
200: '#bfdbfe', 200: "#bfdbfe",
300: '#93c5fd', 300: "#93c5fd",
400: '#60a5fa', 400: "#60a5fa",
500: '#3b82f6', 500: "#3b82f6",
600: '#2563eb', 600: "#2563eb",
700: '#1d4ed8', 700: "#1d4ed8",
800: '#1e40af', 800: "#1e40af",
900: '#1e3a8a', 900: "#1e3a8a",
}, },
secondary: { secondary: {
50: '#f8fafc', 50: "#f8fafc",
100: '#f1f5f9', 100: "#f1f5f9",
200: '#e2e8f0', 200: "#e2e8f0",
300: '#cbd5e1', 300: "#cbd5e1",
400: '#94a3b8', 400: "#94a3b8",
500: '#64748b', 500: "#64748b",
600: '#475569', 600: "#475569",
700: '#334155', 700: "#334155",
800: '#1e293b', 800: "#1e293b",
900: '#0f172a', 900: "#0f172a",
}, },
success: { success: {
50: '#f0fdf4', 50: "#f0fdf4",
100: '#dcfce7', 100: "#dcfce7",
200: '#bbf7d0', 200: "#bbf7d0",
300: '#86efac', 300: "#86efac",
400: '#4ade80', 400: "#4ade80",
500: '#22c55e', 500: "#22c55e",
600: '#16a34a', 600: "#16a34a",
700: '#15803d', 700: "#15803d",
800: '#166534', 800: "#166534",
900: '#14532d', 900: "#14532d",
}, },
warning: { warning: {
50: '#fffbeb', 50: "#fffbeb",
100: '#fef3c7', 100: "#fef3c7",
200: '#fde68a', 200: "#fde68a",
300: '#fcd34d', 300: "#fcd34d",
400: '#fbbf24', 400: "#fbbf24",
500: '#f59e0b', 500: "#f59e0b",
600: '#d97706', 600: "#d97706",
700: '#b45309', 700: "#b45309",
800: '#92400e', 800: "#92400e",
900: '#78350f', 900: "#78350f",
}, },
danger: { danger: {
50: '#fef2f2', 50: "#fef2f2",
100: '#fee2e2', 100: "#fee2e2",
200: '#fecaca', 200: "#fecaca",
300: '#fca5a5', 300: "#fca5a5",
400: '#f87171', 400: "#f87171",
500: '#ef4444', 500: "#ef4444",
600: '#dc2626', 600: "#dc2626",
700: '#b91c1c', 700: "#b91c1c",
800: '#991b1b', 800: "#991b1b",
900: '#7f1d1d', 900: "#7f1d1d",
}, },
}, },
fontFamily: { fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui'], sans: ["Inter", "ui-sans-serif", "system-ui"],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'], mono: ["JetBrains Mono", "ui-monospace", "monospace"],
}, },
boxShadow: { boxShadow: {
'card': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', 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-hover":
'card-dark': '0 1px 3px 0 rgba(255, 255, 255, 0.1), 0 1px 2px 0 rgba(255, 255, 255, 0.06)', "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 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-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: [], 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/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 3000, port: 3000,
host: "0.0.0.0", // Listen on all interfaces
strictPort: true, // Exit if port is already in use strictPort: true, // Exit if port is already in use
allowedHosts: ['localhost'], allowedHosts: true, // Allow all hosts in development
proxy: { proxy: {
'/api': { "/api": {
target: 'http://localhost:3001', target: `http://${process.env.BACKEND_HOST}:${process.env.BACKEND_PORT}`,
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
configure: process.env.VITE_ENABLE_LOGGING === 'true' ? (proxy, options) => { configure:
proxy.on('error', (err, req, res) => { process.env.VITE_ENABLE_LOGGING === "true"
console.log('proxy error', err); ? (proxy, options) => {
proxy.on("error", (err, req, res) => {
console.log("proxy error", err);
}); });
proxy.on('proxyReq', (proxyReq, req, res) => { proxy.on("proxyReq", (proxyReq, req, res) => {
console.log('Sending Request to the Target:', req.method, req.url); console.log(
"Sending Request to the Target:",
req.method,
req.url,
);
}); });
proxy.on('proxyRes', (proxyRes, req, res) => { proxy.on("proxyRes", (proxyRes, req, res) => {
console.log('Received Response from the Target:', proxyRes.statusCode, req.url); console.log(
"Received Response from the Target:",
proxyRes.statusCode,
req.url,
);
}); });
} : undefined, }
: undefined,
}, },
}, },
}, },
build: { build: {
outDir: 'dist', outDir: "dist",
sourcemap: process.env.NODE_ENV !== 'production', sourcemap: process.env.NODE_ENV !== "production",
target: 'es2018', target: "es2018",
}, },
}) });