mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-05 22:43:23 +00:00
style(frontend): fmt
This commit is contained in:
@@ -3,4 +3,4 @@ export default {
|
|||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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")}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
};
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user