mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-04 22:13:21 +00:00
style(frontend): fmt
This commit is contained in:
@@ -3,4 +3,4 @@ export default {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,45 +1,50 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import cors from 'cors';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend:3001';
|
||||
const BACKEND_URL = process.env.BACKEND_URL || "http://backend:3001";
|
||||
|
||||
// Enable CORS for API calls
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
credentials: true
|
||||
}));
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || "*",
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Proxy API requests to backend
|
||||
app.use('/api', createProxyMiddleware({
|
||||
app.use(
|
||||
"/api",
|
||||
createProxyMiddleware({
|
||||
target: BACKEND_URL,
|
||||
changeOrigin: true,
|
||||
logLevel: 'info',
|
||||
logLevel: "info",
|
||||
onError: (err, req, res) => {
|
||||
console.error('Proxy error:', err.message);
|
||||
res.status(500).json({ error: 'Backend service unavailable' });
|
||||
console.error("Proxy error:", err.message);
|
||||
res.status(500).json({ error: "Backend service unavailable" });
|
||||
},
|
||||
onProxyReq: (proxyReq, req, res) => {
|
||||
console.log(`Proxying ${req.method} ${req.path} to ${BACKEND_URL}`);
|
||||
}
|
||||
}));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Serve static files from dist directory
|
||||
app.use(express.static(path.join(__dirname, 'dist')));
|
||||
app.use(express.static(path.join(__dirname, "dist")));
|
||||
|
||||
// Handle SPA routing - serve index.html for all routes
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "dist", "index.html"));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Frontend server running on port ${PORT}`);
|
||||
console.log(`Serving from: ${path.join(__dirname, 'dist')}`);
|
||||
console.log(`Serving from: ${path.join(__dirname, "dist")}`);
|
||||
});
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import React from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { UpdateNotificationProvider } from './contexts/UpdateNotificationContext'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import Layout from './components/Layout'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Hosts from './pages/Hosts'
|
||||
import Packages from './pages/Packages'
|
||||
import Repositories from './pages/Repositories'
|
||||
import RepositoryDetail from './pages/RepositoryDetail'
|
||||
import Users from './pages/Users'
|
||||
import Permissions from './pages/Permissions'
|
||||
import Settings from './pages/Settings'
|
||||
import Options from './pages/Options'
|
||||
import Profile from './pages/Profile'
|
||||
import HostDetail from './pages/HostDetail'
|
||||
import PackageDetail from './pages/PackageDetail'
|
||||
import FirstTimeAdminSetup from './components/FirstTimeAdminSetup'
|
||||
import React from "react";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
|
||||
import Layout from "./components/Layout";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import HostDetail from "./pages/HostDetail";
|
||||
import Hosts from "./pages/Hosts";
|
||||
import Login from "./pages/Login";
|
||||
import Options from "./pages/Options";
|
||||
import PackageDetail from "./pages/PackageDetail";
|
||||
import Packages from "./pages/Packages";
|
||||
import Permissions from "./pages/Permissions";
|
||||
import Profile from "./pages/Profile";
|
||||
import Repositories from "./pages/Repositories";
|
||||
import RepositoryDetail from "./pages/RepositoryDetail";
|
||||
import Settings from "./pages/Settings";
|
||||
import Users from "./pages/Users";
|
||||
|
||||
function AppRoutes() {
|
||||
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth()
|
||||
const isAuth = isAuthenticated() // Call the function to get boolean value
|
||||
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth();
|
||||
const isAuth = isAuthenticated(); // Call the function to get boolean value
|
||||
|
||||
// Show loading while checking if setup is needed
|
||||
if (checkingSetup) {
|
||||
@@ -30,106 +30,144 @@ function AppRoutes() {
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">Checking system status...</p>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
Checking system status...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Show first-time setup if no admin users exist
|
||||
if (needsFirstTimeSetup && !isAuth) {
|
||||
return <FirstTimeAdminSetup />
|
||||
return <FirstTimeAdminSetup />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_dashboard">
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/hosts" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/hosts"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<Hosts />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/hosts/:hostId" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/hosts/:hostId"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<HostDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/packages" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packages"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_packages">
|
||||
<Layout>
|
||||
<Packages />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/repositories" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/repositories"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<Repositories />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/repositories/:repositoryId" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/repositories/:repositoryId"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<RepositoryDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/users" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_users">
|
||||
<Layout>
|
||||
<Users />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/permissions" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/permissions"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<Permissions />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<Settings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/options" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/options"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_hosts">
|
||||
<Layout>
|
||||
<Options />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Profile />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/packages/:packageId" element={
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packages/:packageId"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_packages">
|
||||
<Layout>
|
||||
<PackageDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
@@ -141,7 +179,7 @@ function App() {
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import {
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
X,
|
||||
GripVertical,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Save,
|
||||
GripVertical,
|
||||
RotateCcw,
|
||||
Settings as SettingsIcon
|
||||
} from 'lucide-react';
|
||||
import { dashboardPreferencesAPI } from '../utils/api';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
Save,
|
||||
Settings as SettingsIcon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
import { dashboardPreferencesAPI } from "../utils/api";
|
||||
|
||||
// Sortable Card Item Component
|
||||
const SortableCardItem = ({ card, onToggle }) => {
|
||||
@@ -53,7 +51,7 @@ const SortableCardItem = ({ card, onToggle }) => {
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${
|
||||
isDragging ? 'shadow-lg' : 'shadow-sm'
|
||||
isDragging ? "shadow-lg" : "shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -68,7 +66,9 @@ const SortableCardItem = ({ card, onToggle }) => {
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{card.title}
|
||||
{card.typeLabel ? (
|
||||
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">({card.typeLabel})</span>
|
||||
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">
|
||||
({card.typeLabel})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,8 +78,8 @@ const SortableCardItem = ({ card, onToggle }) => {
|
||||
onClick={() => onToggle(card.cardId)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||
card.enabled
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800'
|
||||
: 'bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
|
||||
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800"
|
||||
: "bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600"
|
||||
}`}
|
||||
>
|
||||
{card.enabled ? (
|
||||
@@ -108,21 +108,22 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Fetch user's dashboard preferences
|
||||
const { data: preferences, isLoading } = useQuery({
|
||||
queryKey: ['dashboardPreferences'],
|
||||
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
|
||||
enabled: isOpen
|
||||
queryKey: ["dashboardPreferences"],
|
||||
queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Fetch default card configuration
|
||||
const { data: defaultCards } = useQuery({
|
||||
queryKey: ['dashboardDefaultCards'],
|
||||
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
|
||||
enabled: isOpen
|
||||
queryKey: ["dashboardDefaultCards"],
|
||||
queryFn: () =>
|
||||
dashboardPreferencesAPI.getDefaults().then((res) => res.data),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Update preferences mutation
|
||||
@@ -130,15 +131,18 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
|
||||
onSuccess: (response) => {
|
||||
// Optimistically update the query cache with the correct data structure
|
||||
queryClient.setQueryData(['dashboardPreferences'], response.data.preferences);
|
||||
queryClient.setQueryData(
|
||||
["dashboardPreferences"],
|
||||
response.data.preferences,
|
||||
);
|
||||
// Also invalidate to ensure fresh data
|
||||
queryClient.invalidateQueries(['dashboardPreferences']);
|
||||
queryClient.invalidateQueries(["dashboardPreferences"]);
|
||||
setHasChanges(false);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update dashboard preferences:', error);
|
||||
}
|
||||
console.error("Failed to update dashboard preferences:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize cards when preferences or defaults are loaded
|
||||
@@ -152,14 +156,26 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
}));
|
||||
|
||||
const typeLabelFor = (cardId) => {
|
||||
if (['totalHosts','hostsNeedingUpdates','totalOutdatedPackages','securityUpdates','upToDateHosts','totalHostGroups','totalUsers','totalRepos'].includes(cardId)) return 'Top card';
|
||||
if (cardId === 'osDistribution') return 'Pie chart';
|
||||
if (cardId === 'osDistributionBar') return 'Bar chart';
|
||||
if (cardId === 'updateStatus') return 'Pie chart';
|
||||
if (cardId === 'packagePriority') return 'Pie chart';
|
||||
if (cardId === 'recentUsers') return 'Table';
|
||||
if (cardId === 'recentCollection') return 'Table';
|
||||
if (cardId === 'quickStats') return 'Wide card';
|
||||
if (
|
||||
[
|
||||
"totalHosts",
|
||||
"hostsNeedingUpdates",
|
||||
"totalOutdatedPackages",
|
||||
"securityUpdates",
|
||||
"upToDateHosts",
|
||||
"totalHostGroups",
|
||||
"totalUsers",
|
||||
"totalRepos",
|
||||
].includes(cardId)
|
||||
)
|
||||
return "Top card";
|
||||
if (cardId === "osDistribution") return "Pie chart";
|
||||
if (cardId === "osDistributionBar") return "Bar chart";
|
||||
if (cardId === "updateStatus") return "Pie chart";
|
||||
if (cardId === "packagePriority") return "Pie chart";
|
||||
if (cardId === "recentUsers") return "Table";
|
||||
if (cardId === "recentCollection") return "Table";
|
||||
if (cardId === "quickStats") return "Wide card";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -167,11 +183,13 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
const mergedCards = defaultCards
|
||||
.map((defaultCard) => {
|
||||
const userPreference = normalizedPreferences.find(
|
||||
(p) => p.cardId === defaultCard.cardId
|
||||
(p) => p.cardId === defaultCard.cardId,
|
||||
);
|
||||
return {
|
||||
...defaultCard,
|
||||
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
|
||||
enabled: userPreference
|
||||
? userPreference.enabled
|
||||
: defaultCard.enabled,
|
||||
order: userPreference ? userPreference.order : defaultCard.order,
|
||||
typeLabel: typeLabelFor(defaultCard.cardId),
|
||||
};
|
||||
@@ -187,15 +205,15 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
|
||||
if (active.id !== over.id) {
|
||||
setCards((items) => {
|
||||
const oldIndex = items.findIndex(item => item.cardId === active.id);
|
||||
const newIndex = items.findIndex(item => item.cardId === over.id);
|
||||
const oldIndex = items.findIndex((item) => item.cardId === active.id);
|
||||
const newIndex = items.findIndex((item) => item.cardId === over.id);
|
||||
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
// Update order values
|
||||
return newItems.map((item, index) => ({
|
||||
...item,
|
||||
order: index
|
||||
order: index,
|
||||
}));
|
||||
});
|
||||
setHasChanges(true);
|
||||
@@ -203,21 +221,19 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
};
|
||||
|
||||
const handleToggle = (cardId) => {
|
||||
setCards(prevCards =>
|
||||
prevCards.map(card =>
|
||||
card.cardId === cardId
|
||||
? { ...card, enabled: !card.enabled }
|
||||
: card
|
||||
)
|
||||
setCards((prevCards) =>
|
||||
prevCards.map((card) =>
|
||||
card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
|
||||
),
|
||||
);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const preferences = cards.map(card => ({
|
||||
const preferences = cards.map((card) => ({
|
||||
cardId: card.cardId,
|
||||
enabled: card.enabled,
|
||||
order: card.order
|
||||
order: card.order,
|
||||
}));
|
||||
|
||||
updatePreferencesMutation.mutate(preferences);
|
||||
@@ -225,10 +241,10 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
|
||||
const handleReset = () => {
|
||||
if (defaultCards) {
|
||||
const resetCards = defaultCards.map(card => ({
|
||||
const resetCards = defaultCards.map((card) => ({
|
||||
...card,
|
||||
enabled: true,
|
||||
order: card.order
|
||||
order: card.order,
|
||||
}));
|
||||
setCards(resetCards);
|
||||
setHasChanges(true);
|
||||
@@ -240,7 +256,10 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
||||
<div
|
||||
className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
@@ -260,8 +279,9 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
|
||||
Customize your dashboard by reordering cards and toggling their visibility.
|
||||
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
|
||||
Customize your dashboard by reordering cards and toggling their
|
||||
visibility. Drag cards to reorder them, and click the visibility
|
||||
toggle to show/hide cards.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -274,7 +294,10 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={cards.map(card => card.cardId)} strategy={verticalListSortingStrategy}>
|
||||
<SortableContext
|
||||
items={cards.map((card) => card.cardId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{cards.map((card) => (
|
||||
<SortableCardItem
|
||||
@@ -295,8 +318,8 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
disabled={!hasChanges || updatePreferencesMutation.isPending}
|
||||
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
|
||||
!hasChanges || updatePreferencesMutation.isPending
|
||||
? 'bg-secondary-400 cursor-not-allowed'
|
||||
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
|
||||
? "bg-secondary-400 cursor-not-allowed"
|
||||
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
}`}
|
||||
>
|
||||
{updatePreferencesMutation.isPending ? (
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { UserPlus, Shield, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import { AlertCircle, CheckCircle, Shield, UserPlus } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
const FirstTimeAdminSetup = () => {
|
||||
const { login } = useAuth()
|
||||
const { login } = useAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
firstName: '',
|
||||
lastName: ''
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}))
|
||||
[name]: value,
|
||||
}));
|
||||
// Clear error when user starts typing
|
||||
if (error) setError('')
|
||||
}
|
||||
if (error) setError("");
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!formData.firstName.trim()) {
|
||||
setError('First name is required')
|
||||
return false
|
||||
setError("First name is required");
|
||||
return false;
|
||||
}
|
||||
if (!formData.lastName.trim()) {
|
||||
setError('Last name is required')
|
||||
return false
|
||||
setError("Last name is required");
|
||||
return false;
|
||||
}
|
||||
if (!formData.username.trim()) {
|
||||
setError('Username is required')
|
||||
return false
|
||||
setError("Username is required");
|
||||
return false;
|
||||
}
|
||||
if (!formData.email.trim()) {
|
||||
setError('Email address is required')
|
||||
return false
|
||||
setError("Email address is required");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enhanced email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(formData.email.trim())) {
|
||||
setError('Please enter a valid email address (e.g., user@example.com)')
|
||||
return false
|
||||
setError("Please enter a valid email address (e.g., user@example.com)");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.password.length < 8) {
|
||||
setError('Password must be at least 8 characters for security')
|
||||
return false
|
||||
setError("Password must be at least 8 characters for security");
|
||||
return false;
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
setError("Passwords do not match");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/setup-admin', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/api/v1/auth/setup-admin", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: formData.username.trim(),
|
||||
email: formData.email.trim(),
|
||||
password: formData.password,
|
||||
firstName: formData.firstName.trim(),
|
||||
lastName: formData.lastName.trim()
|
||||
})
|
||||
})
|
||||
lastName: formData.lastName.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json()
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess(true)
|
||||
setSuccess(true);
|
||||
// Auto-login the user after successful setup
|
||||
setTimeout(() => {
|
||||
login(formData.username.trim(), formData.password)
|
||||
}, 2000)
|
||||
login(formData.username.trim(), formData.password);
|
||||
}, 2000);
|
||||
} else {
|
||||
setError(data.error || 'Failed to create admin user')
|
||||
setError(data.error || "Failed to create admin user");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error)
|
||||
setError('Network error. Please try again.')
|
||||
console.error("Setup error:", error);
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
@@ -118,7 +118,8 @@ const FirstTimeAdminSetup = () => {
|
||||
Admin Account Created!
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Your admin account has been successfully created. You will be automatically logged in shortly.
|
||||
Your admin account has been successfully created. You will be
|
||||
automatically logged in shortly.
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||
@@ -126,7 +127,7 @@ const FirstTimeAdminSetup = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -151,7 +152,9 @@ const FirstTimeAdminSetup = () => {
|
||||
<div className="mb-6 p-4 bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<AlertCircle className="h-5 w-5 text-danger-600 dark:text-danger-400 mr-2" />
|
||||
<span className="text-danger-700 dark:text-danger-300 text-sm">{error}</span>
|
||||
<span className="text-danger-700 dark:text-danger-300 text-sm">
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -159,7 +162,10 @@ const FirstTimeAdminSetup = () => {
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<label
|
||||
htmlFor="firstName"
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||
>
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
@@ -175,7 +181,10 @@ const FirstTimeAdminSetup = () => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<label
|
||||
htmlFor="lastName"
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||
>
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
@@ -193,7 +202,10 @@ const FirstTimeAdminSetup = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
@@ -210,7 +222,10 @@ const FirstTimeAdminSetup = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
@@ -227,7 +242,10 @@ const FirstTimeAdminSetup = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
@@ -244,7 +262,10 @@ const FirstTimeAdminSetup = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
@@ -284,14 +305,17 @@ const FirstTimeAdminSetup = () => {
|
||||
<Shield className="h-5 w-5 text-blue-600 dark:text-blue-400 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<p className="font-medium mb-1">Admin Privileges</p>
|
||||
<p>This account will have full administrative access to manage users, hosts, packages, and system settings.</p>
|
||||
<p>
|
||||
This account will have full administrative access to manage
|
||||
users, hosts, packages, and system settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default FirstTimeAdminSetup
|
||||
export default FirstTimeAdminSetup;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Edit2, Check, X } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Check, Edit2, X } from "lucide-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const InlineEdit = ({
|
||||
value,
|
||||
@@ -11,12 +11,12 @@ const InlineEdit = ({
|
||||
className = "",
|
||||
disabled = false,
|
||||
validate = null,
|
||||
linkTo = null
|
||||
linkTo = null,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(value);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [error, setError] = useState("");
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -34,13 +34,13 @@ const InlineEdit = ({
|
||||
if (disabled) return;
|
||||
setIsEditing(true);
|
||||
setEditValue(value);
|
||||
setError('');
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditValue(value);
|
||||
setError('');
|
||||
setError("");
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
@@ -63,23 +63,23 @@ const InlineEdit = ({
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await onSave(editValue.trim());
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to save');
|
||||
setError(err.message || "Failed to save");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
@@ -98,12 +98,12 @@ const InlineEdit = ({
|
||||
maxLength={maxLength}
|
||||
disabled={isLoading}
|
||||
className={`flex-1 px-2 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
|
||||
error ? 'border-red-500' : ''
|
||||
} ${isLoading ? 'opacity-50' : ''}`}
|
||||
error ? "border-red-500" : ""
|
||||
} ${isLoading ? "opacity-50" : ""}`}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || editValue.trim() === ''}
|
||||
disabled={isLoading || editValue.trim() === ""}
|
||||
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save"
|
||||
>
|
||||
@@ -118,7 +118,9 @@ const InlineEdit = ({
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400">{error}</span>
|
||||
<span className="text-xs text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Edit2, Check, X, ChevronDown } from 'lucide-react';
|
||||
import { Check, ChevronDown, Edit2, X } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const InlineGroupEdit = ({
|
||||
value,
|
||||
@@ -8,14 +8,18 @@ const InlineGroupEdit = ({
|
||||
options = [],
|
||||
className = "",
|
||||
disabled = false,
|
||||
placeholder = "Select group..."
|
||||
placeholder = "Select group...",
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState(value);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [error, setError] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const [dropdownPosition, setDropdownPosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
const dropdownRef = useRef(null);
|
||||
const buttonRef = useRef(null);
|
||||
|
||||
@@ -40,7 +44,7 @@ const InlineGroupEdit = ({
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX,
|
||||
width: rect.width
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -55,13 +59,13 @@ const InlineGroupEdit = ({
|
||||
|
||||
if (isOpen) {
|
||||
calculateDropdownPosition();
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
window.addEventListener('resize', calculateDropdownPosition);
|
||||
window.addEventListener('scroll', calculateDropdownPosition);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
window.addEventListener("resize", calculateDropdownPosition);
|
||||
window.addEventListener("scroll", calculateDropdownPosition);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
window.removeEventListener('resize', calculateDropdownPosition);
|
||||
window.removeEventListener('scroll', calculateDropdownPosition);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
window.removeEventListener("resize", calculateDropdownPosition);
|
||||
window.removeEventListener("scroll", calculateDropdownPosition);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
@@ -70,7 +74,7 @@ const InlineGroupEdit = ({
|
||||
if (disabled) return;
|
||||
setIsEditing(true);
|
||||
setSelectedValue(value);
|
||||
setError('');
|
||||
setError("");
|
||||
// Automatically open dropdown when editing starts
|
||||
setTimeout(() => {
|
||||
setIsOpen(true);
|
||||
@@ -80,7 +84,7 @@ const InlineGroupEdit = ({
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setSelectedValue(value);
|
||||
setError('');
|
||||
setError("");
|
||||
setIsOpen(false);
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
@@ -96,7 +100,7 @@ const InlineGroupEdit = ({
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await onSave(selectedValue);
|
||||
@@ -105,17 +109,17 @@ const InlineGroupEdit = ({
|
||||
setIsEditing(false);
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to save');
|
||||
setError(err.message || "Failed to save");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
@@ -123,20 +127,20 @@ const InlineGroupEdit = ({
|
||||
|
||||
const displayValue = useMemo(() => {
|
||||
if (!value) {
|
||||
return 'Ungrouped';
|
||||
return "Ungrouped";
|
||||
}
|
||||
const option = options.find(opt => opt.id === value);
|
||||
return option ? option.name : 'Unknown Group';
|
||||
const option = options.find((opt) => opt.id === value);
|
||||
return option ? option.name : "Unknown Group";
|
||||
}, [value, options]);
|
||||
|
||||
const displayColor = useMemo(() => {
|
||||
if (!value) return 'bg-secondary-100 text-secondary-800';
|
||||
const option = options.find(opt => opt.id === value);
|
||||
return option ? `text-white` : 'bg-secondary-100 text-secondary-800';
|
||||
if (!value) return "bg-secondary-100 text-secondary-800";
|
||||
const option = options.find((opt) => opt.id === value);
|
||||
return option ? `text-white` : "bg-secondary-100 text-secondary-800";
|
||||
}, [value, options]);
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
return options.find(opt => opt.id === value);
|
||||
return options.find((opt) => opt.id === value);
|
||||
}, [value, options]);
|
||||
|
||||
if (isEditing) {
|
||||
@@ -151,11 +155,14 @@ const InlineGroupEdit = ({
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
className={`w-full px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent flex items-center justify-between ${
|
||||
error ? 'border-red-500' : ''
|
||||
} ${isLoading ? 'opacity-50' : ''}`}
|
||||
error ? "border-red-500" : ""
|
||||
} ${isLoading ? "opacity-50" : ""}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedValue ? options.find(opt => opt.id === selectedValue)?.name || 'Unknown Group' : 'Ungrouped'}
|
||||
{selectedValue
|
||||
? options.find((opt) => opt.id === selectedValue)?.name ||
|
||||
"Unknown Group"
|
||||
: "Ungrouped"}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||
</button>
|
||||
@@ -167,7 +174,7 @@ const InlineGroupEdit = ({
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`,
|
||||
minWidth: '200px'
|
||||
minWidth: "200px",
|
||||
}}
|
||||
>
|
||||
<div className="py-1">
|
||||
@@ -178,7 +185,9 @@ const InlineGroupEdit = ({
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
|
||||
selectedValue === null ? 'bg-primary-50 dark:bg-primary-900/20' : ''
|
||||
selectedValue === null
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
|
||||
@@ -194,7 +203,9 @@ const InlineGroupEdit = ({
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
|
||||
selectedValue === option.id ? 'bg-primary-50 dark:bg-primary-900/20' : ''
|
||||
selectedValue === option.id
|
||||
? "bg-primary-50 dark:bg-primary-900/20"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -227,7 +238,9 @@ const InlineGroupEdit = ({
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">{error}</span>
|
||||
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,268 +1,317 @@
|
||||
import React from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Home,
|
||||
Server,
|
||||
Package,
|
||||
Shield,
|
||||
Activity,
|
||||
BarChart3,
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
User,
|
||||
Users,
|
||||
Settings,
|
||||
UserCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
Wrench,
|
||||
Container,
|
||||
Plus,
|
||||
Activity,
|
||||
Cog,
|
||||
Container,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Github,
|
||||
MessageCircle,
|
||||
Globe,
|
||||
Home,
|
||||
LogOut,
|
||||
Mail,
|
||||
Menu,
|
||||
MessageCircle,
|
||||
Package,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Star,
|
||||
Globe
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useUpdateNotification } from '../contexts/UpdateNotificationContext'
|
||||
import { dashboardAPI, formatRelativeTime, versionAPI } from '../utils/api'
|
||||
import UpgradeNotificationIcon from './UpgradeNotificationIcon'
|
||||
User,
|
||||
UserCircle,
|
||||
Users,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
||||
import { dashboardAPI, formatRelativeTime, versionAPI } from "../utils/api";
|
||||
import UpgradeNotificationIcon from "./UpgradeNotificationIcon";
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
// Load sidebar state from localStorage, default to false
|
||||
const saved = localStorage.getItem('sidebarCollapsed')
|
||||
return saved ? JSON.parse(saved) : false
|
||||
})
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const [githubStars, setGithubStars] = useState(null)
|
||||
const location = useLocation()
|
||||
const { user, logout, canViewDashboard, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canViewReports, canExportData, canManageSettings } = useAuth()
|
||||
const { updateAvailable } = useUpdateNotification()
|
||||
const userMenuRef = useRef(null)
|
||||
const saved = localStorage.getItem("sidebarCollapsed");
|
||||
return saved ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const [githubStars, setGithubStars] = useState(null);
|
||||
const location = useLocation();
|
||||
const {
|
||||
user,
|
||||
logout,
|
||||
canViewDashboard,
|
||||
canViewHosts,
|
||||
canManageHosts,
|
||||
canViewPackages,
|
||||
canViewUsers,
|
||||
canManageUsers,
|
||||
canViewReports,
|
||||
canExportData,
|
||||
canManageSettings,
|
||||
} = useAuth();
|
||||
const { updateAvailable } = useUpdateNotification();
|
||||
const userMenuRef = useRef(null);
|
||||
|
||||
// Fetch dashboard stats for the "Last updated" info
|
||||
const { data: stats, refetch, isFetching } = useQuery({
|
||||
queryKey: ['dashboardStats'],
|
||||
queryFn: () => dashboardAPI.getStats().then(res => res.data),
|
||||
const {
|
||||
data: stats,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["dashboardStats"],
|
||||
queryFn: () => dashboardAPI.getStats().then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch version info
|
||||
const { data: versionInfo } = useQuery({
|
||||
queryKey: ['versionInfo'],
|
||||
queryFn: () => versionAPI.getCurrent().then(res => res.data),
|
||||
queryKey: ["versionInfo"],
|
||||
queryFn: () => versionAPI.getCurrent().then((res) => res.data),
|
||||
staleTime: 300000, // Consider data stale after 5 minutes
|
||||
})
|
||||
});
|
||||
|
||||
// Build navigation based on permissions
|
||||
const buildNavigation = () => {
|
||||
const nav = []
|
||||
const nav = [];
|
||||
|
||||
// Dashboard - only show if user can view dashboard
|
||||
if (canViewDashboard()) {
|
||||
nav.push({ name: 'Dashboard', href: '/', icon: Home })
|
||||
nav.push({ name: "Dashboard", href: "/", icon: Home });
|
||||
}
|
||||
|
||||
// Inventory section - only show if user has any inventory permissions
|
||||
if (canViewHosts() || canViewPackages() || canViewReports()) {
|
||||
const inventoryItems = []
|
||||
const inventoryItems = [];
|
||||
|
||||
if (canViewHosts()) {
|
||||
inventoryItems.push({ name: 'Hosts', href: '/hosts', icon: Server })
|
||||
inventoryItems.push({ name: 'Repos', href: '/repositories', icon: GitBranch })
|
||||
inventoryItems.push({ name: "Hosts", href: "/hosts", icon: Server });
|
||||
inventoryItems.push({
|
||||
name: "Repos",
|
||||
href: "/repositories",
|
||||
icon: GitBranch,
|
||||
});
|
||||
}
|
||||
|
||||
if (canViewPackages()) {
|
||||
inventoryItems.push({ name: 'Packages', href: '/packages', icon: Package })
|
||||
inventoryItems.push({
|
||||
name: "Packages",
|
||||
href: "/packages",
|
||||
icon: Package,
|
||||
});
|
||||
}
|
||||
|
||||
if (canViewReports()) {
|
||||
inventoryItems.push(
|
||||
{ name: 'Services', href: '/services', icon: Activity, comingSoon: true },
|
||||
{ name: 'Docker', href: '/docker', icon: Container, comingSoon: true },
|
||||
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true }
|
||||
)
|
||||
{
|
||||
name: "Services",
|
||||
href: "/services",
|
||||
icon: Activity,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
name: "Docker",
|
||||
href: "/docker",
|
||||
icon: Container,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
name: "Reporting",
|
||||
href: "/reporting",
|
||||
icon: BarChart3,
|
||||
comingSoon: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (inventoryItems.length > 0) {
|
||||
nav.push({
|
||||
section: 'Inventory',
|
||||
items: inventoryItems
|
||||
})
|
||||
section: "Inventory",
|
||||
items: inventoryItems,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// PatchMon Users section - only show if user can view/manage users
|
||||
if (canViewUsers() || canManageUsers()) {
|
||||
const userItems = []
|
||||
const userItems = [];
|
||||
|
||||
if (canViewUsers()) {
|
||||
userItems.push({ name: 'Users', href: '/users', icon: Users })
|
||||
userItems.push({ name: "Users", href: "/users", icon: Users });
|
||||
}
|
||||
|
||||
if (canManageSettings()) {
|
||||
userItems.push({ name: 'Permissions', href: '/permissions', icon: Shield })
|
||||
userItems.push({
|
||||
name: "Permissions",
|
||||
href: "/permissions",
|
||||
icon: Shield,
|
||||
});
|
||||
}
|
||||
|
||||
if (userItems.length > 0) {
|
||||
nav.push({
|
||||
section: 'PatchMon Users',
|
||||
items: userItems
|
||||
})
|
||||
section: "PatchMon Users",
|
||||
items: userItems,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Settings section - only show if user has any settings permissions
|
||||
if (canManageSettings() || canViewReports() || canExportData()) {
|
||||
const settingsItems = []
|
||||
const settingsItems = [];
|
||||
|
||||
if (canManageSettings()) {
|
||||
settingsItems.push({
|
||||
name: 'PatchMon Options',
|
||||
href: '/options',
|
||||
icon: Settings
|
||||
})
|
||||
name: "PatchMon Options",
|
||||
href: "/options",
|
||||
icon: Settings,
|
||||
});
|
||||
settingsItems.push({
|
||||
name: 'Server Config',
|
||||
href: '/settings',
|
||||
name: "Server Config",
|
||||
href: "/settings",
|
||||
icon: Wrench,
|
||||
showUpgradeIcon: updateAvailable
|
||||
})
|
||||
showUpgradeIcon: updateAvailable,
|
||||
});
|
||||
}
|
||||
|
||||
if (canViewReports() || canExportData()) {
|
||||
settingsItems.push({
|
||||
name: 'Audit Log',
|
||||
href: '/audit-log',
|
||||
name: "Audit Log",
|
||||
href: "/audit-log",
|
||||
icon: FileText,
|
||||
comingSoon: true
|
||||
})
|
||||
comingSoon: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsItems.length > 0) {
|
||||
nav.push({
|
||||
section: 'Settings',
|
||||
items: settingsItems
|
||||
})
|
||||
section: "Settings",
|
||||
items: settingsItems,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nav
|
||||
}
|
||||
return nav;
|
||||
};
|
||||
|
||||
const navigation = buildNavigation()
|
||||
const navigation = buildNavigation();
|
||||
|
||||
const isActive = (path) => location.pathname === path
|
||||
const isActive = (path) => location.pathname === path;
|
||||
|
||||
// Get page title based on current route
|
||||
const getPageTitle = () => {
|
||||
const path = location.pathname
|
||||
const path = location.pathname;
|
||||
|
||||
if (path === '/') return 'Dashboard'
|
||||
if (path === '/hosts') return 'Hosts'
|
||||
if (path === '/packages') return 'Packages'
|
||||
if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories'
|
||||
if (path === '/services') return 'Services'
|
||||
if (path === '/docker') return 'Docker'
|
||||
if (path === '/users') return 'Users'
|
||||
if (path === '/permissions') return 'Permissions'
|
||||
if (path === '/settings') return 'Settings'
|
||||
if (path === '/options') return 'PatchMon Options'
|
||||
if (path === '/audit-log') return 'Audit Log'
|
||||
if (path === '/profile') return 'My Profile'
|
||||
if (path.startsWith('/hosts/')) return 'Host Details'
|
||||
if (path.startsWith('/packages/')) return 'Package Details'
|
||||
if (path === "/") return "Dashboard";
|
||||
if (path === "/hosts") return "Hosts";
|
||||
if (path === "/packages") return "Packages";
|
||||
if (path === "/repositories" || path.startsWith("/repositories/"))
|
||||
return "Repositories";
|
||||
if (path === "/services") return "Services";
|
||||
if (path === "/docker") return "Docker";
|
||||
if (path === "/users") return "Users";
|
||||
if (path === "/permissions") return "Permissions";
|
||||
if (path === "/settings") return "Settings";
|
||||
if (path === "/options") return "PatchMon Options";
|
||||
if (path === "/audit-log") return "Audit Log";
|
||||
if (path === "/profile") return "My Profile";
|
||||
if (path.startsWith("/hosts/")) return "Host Details";
|
||||
if (path.startsWith("/packages/")) return "Package Details";
|
||||
|
||||
return 'PatchMon'
|
||||
}
|
||||
return "PatchMon";
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
await logout();
|
||||
setUserMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleAddHost = () => {
|
||||
// Navigate to hosts page with add modal parameter
|
||||
window.location.href = '/hosts?action=add'
|
||||
}
|
||||
|
||||
window.location.href = "/hosts?action=add";
|
||||
};
|
||||
|
||||
// Fetch GitHub stars count
|
||||
const fetchGitHubStars = async () => {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/repos/9technologygroup/patchmon.net')
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/9technologygroup/patchmon.net",
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setGithubStars(data.stargazers_count)
|
||||
const data = await response.json();
|
||||
setGithubStars(data.stargazers_count);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub stars:', error)
|
||||
}
|
||||
console.error("Failed to fetch GitHub stars:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Short format for navigation area
|
||||
const formatRelativeTimeShort = (date) => {
|
||||
if (!date) return 'Never'
|
||||
if (!date) return "Never";
|
||||
|
||||
const now = new Date()
|
||||
const dateObj = new Date(date)
|
||||
const now = new Date();
|
||||
const dateObj = new Date(date);
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(dateObj.getTime())) return 'Invalid date'
|
||||
if (isNaN(dateObj.getTime())) return "Invalid date";
|
||||
|
||||
const diff = now - dateObj
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
const diff = now - dateObj;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return `${seconds}s ago`
|
||||
}
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return `${seconds}s ago`;
|
||||
};
|
||||
|
||||
// Save sidebar collapsed state to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed))
|
||||
}, [sidebarCollapsed])
|
||||
localStorage.setItem("sidebarCollapsed", JSON.stringify(sidebarCollapsed));
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
// Close user menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(event.target)) {
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
setUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch GitHub stars on component mount
|
||||
useEffect(() => {
|
||||
fetchGitHubStars()
|
||||
}, [])
|
||||
fetchGitHubStars();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-50">
|
||||
{/* Mobile sidebar */}
|
||||
<div className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? 'block' : 'hidden'}`}>
|
||||
<div className="fixed inset-0 bg-secondary-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
|
||||
<div
|
||||
className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 bg-secondary-600 bg-opacity-75"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<div className="relative flex w-full max-w-xs flex-col bg-white pb-4 pt-5 shadow-xl">
|
||||
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
||||
<button
|
||||
@@ -276,7 +325,9 @@ const Layout = ({ children }) => {
|
||||
<div className="flex flex-shrink-0 items-center px-4">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-8 w-8 text-primary-600" />
|
||||
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">PatchMon</h1>
|
||||
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
|
||||
PatchMon
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mt-8 flex-1 space-y-6 px-2">
|
||||
@@ -285,7 +336,9 @@ const Layout = ({ children }) => {
|
||||
<div className="px-2 py-4 text-center">
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<p className="mb-2">Limited access</p>
|
||||
<p className="text-xs">Contact your administrator for additional permissions</p>
|
||||
<p className="text-xs">
|
||||
Contact your administrator for additional permissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -298,15 +351,15 @@ const Layout = ({ children }) => {
|
||||
to={item.href}
|
||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary-100 text-primary-900'
|
||||
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
|
||||
? "bg-primary-100 text-primary-900"
|
||||
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
|
||||
}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon className="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
} else if (item.section) {
|
||||
// Section with items
|
||||
return (
|
||||
@@ -317,14 +370,14 @@ const Layout = ({ children }) => {
|
||||
<div className="space-y-1">
|
||||
{item.items.map((subItem) => (
|
||||
<div key={subItem.name}>
|
||||
{subItem.name === 'Hosts' && canManageHosts() ? (
|
||||
{subItem.name === "Hosts" && canManageHosts() ? (
|
||||
// Special handling for Hosts item with integrated + button (mobile)
|
||||
<Link
|
||||
to={subItem.href}
|
||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
||||
isActive(subItem.href)
|
||||
? 'bg-primary-100 text-primary-900'
|
||||
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
|
||||
? "bg-primary-100 text-primary-900"
|
||||
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
|
||||
}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
@@ -334,9 +387,9 @@ const Layout = ({ children }) => {
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setSidebarOpen(false)
|
||||
handleAddHost()
|
||||
e.preventDefault();
|
||||
setSidebarOpen(false);
|
||||
handleAddHost();
|
||||
}}
|
||||
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
|
||||
title="Add Host"
|
||||
@@ -350,10 +403,14 @@ const Layout = ({ children }) => {
|
||||
to={subItem.href}
|
||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
||||
isActive(subItem.href)
|
||||
? 'bg-primary-100 text-primary-900'
|
||||
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
|
||||
} ${subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={subItem.comingSoon ? (e) => e.preventDefault() : () => setSidebarOpen(false)}
|
||||
? "bg-primary-100 text-primary-900"
|
||||
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
|
||||
} ${subItem.comingSoon ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
onClick={
|
||||
subItem.comingSoon
|
||||
? (e) => e.preventDefault()
|
||||
: () => setSidebarOpen(false)
|
||||
}
|
||||
>
|
||||
<subItem.icon className="mr-3 h-5 w-5" />
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -370,24 +427,30 @@ const Layout = ({ children }) => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'lg:w-16' : 'lg:w-64'
|
||||
} bg-white dark:bg-secondary-800`}>
|
||||
<div className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
|
||||
sidebarCollapsed ? 'px-2 shadow-lg' : 'px-6'
|
||||
}`}>
|
||||
<div className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
|
||||
sidebarCollapsed ? 'justify-center' : 'justify-between'
|
||||
}`}>
|
||||
<div
|
||||
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
|
||||
sidebarCollapsed ? "lg:w-16" : "lg:w-64"
|
||||
} bg-white dark:bg-secondary-800`}
|
||||
>
|
||||
<div
|
||||
className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
|
||||
sidebarCollapsed ? "px-2 shadow-lg" : "px-6"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
|
||||
sidebarCollapsed ? "justify-center" : "justify-between"
|
||||
}`}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
@@ -400,7 +463,9 @@ const Layout = ({ children }) => {
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-8 w-8 text-primary-600" />
|
||||
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">PatchMon</h1>
|
||||
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
|
||||
PatchMon
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
@@ -419,7 +484,9 @@ const Layout = ({ children }) => {
|
||||
<li className="px-2 py-4 text-center">
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<p className="mb-2">Limited access</p>
|
||||
<p className="text-xs">Contact your administrator for additional permissions</p>
|
||||
<p className="text-xs">
|
||||
Contact your administrator for additional permissions
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
@@ -432,18 +499,20 @@ const Layout = ({ children }) => {
|
||||
to={item.href}
|
||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'}`}
|
||||
title={sidebarCollapsed ? item.name : ''}
|
||||
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
|
||||
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
} ${sidebarCollapsed ? "justify-center p-2" : "p-2"}`}
|
||||
title={sidebarCollapsed ? item.name : ""}
|
||||
>
|
||||
<item.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
<item.icon
|
||||
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
|
||||
/>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="truncate">{item.name}</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
} else if (item.section) {
|
||||
// Section with items
|
||||
return (
|
||||
@@ -453,22 +522,26 @@ const Layout = ({ children }) => {
|
||||
{item.section}
|
||||
</h3>
|
||||
)}
|
||||
<ul className={`space-y-1 ${sidebarCollapsed ? '' : '-mx-2'}`}>
|
||||
<ul
|
||||
className={`space-y-1 ${sidebarCollapsed ? "" : "-mx-2"}`}
|
||||
>
|
||||
{item.items.map((subItem) => (
|
||||
<li key={subItem.name}>
|
||||
{subItem.name === 'Hosts' && canManageHosts() ? (
|
||||
{subItem.name === "Hosts" && canManageHosts() ? (
|
||||
// Special handling for Hosts item with integrated + button
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
to={subItem.href}
|
||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 flex-1 ${
|
||||
isActive(subItem.href)
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'}`}
|
||||
title={sidebarCollapsed ? subItem.name : ''}
|
||||
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
|
||||
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
} ${sidebarCollapsed ? "justify-center p-2" : "p-2"}`}
|
||||
title={sidebarCollapsed ? subItem.name : ""}
|
||||
>
|
||||
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
<subItem.icon
|
||||
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
|
||||
/>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="truncate flex items-center gap-2 flex-1">
|
||||
{subItem.name}
|
||||
@@ -477,8 +550,8 @@ const Layout = ({ children }) => {
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleAddHost()
|
||||
e.preventDefault();
|
||||
handleAddHost();
|
||||
}}
|
||||
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
|
||||
title="Add Host"
|
||||
@@ -494,17 +567,28 @@ const Layout = ({ children }) => {
|
||||
to={subItem.href}
|
||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 ${
|
||||
isActive(subItem.href)
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
} ${sidebarCollapsed ? 'justify-center p-2 relative' : 'p-2'} ${
|
||||
subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''
|
||||
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
|
||||
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
} ${sidebarCollapsed ? "justify-center p-2 relative" : "p-2"} ${
|
||||
subItem.comingSoon
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
title={sidebarCollapsed ? subItem.name : ''}
|
||||
onClick={subItem.comingSoon ? (e) => e.preventDefault() : undefined}
|
||||
title={sidebarCollapsed ? subItem.name : ""}
|
||||
onClick={
|
||||
subItem.comingSoon
|
||||
? (e) => e.preventDefault()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className={`flex items-center ${sidebarCollapsed ? 'justify-center' : ''}`}>
|
||||
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
{sidebarCollapsed && subItem.showUpgradeIcon && (
|
||||
<div
|
||||
className={`flex items-center ${sidebarCollapsed ? "justify-center" : ""}`}
|
||||
>
|
||||
<subItem.icon
|
||||
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
|
||||
/>
|
||||
{sidebarCollapsed &&
|
||||
subItem.showUpgradeIcon && (
|
||||
<UpgradeNotificationIcon className="h-3 w-3 absolute -top-1 -right-1" />
|
||||
)}
|
||||
</div>
|
||||
@@ -527,14 +611,13 @@ const Layout = ({ children }) => {
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
{/* Profile Section - Bottom of Sidebar */}
|
||||
<div className="border-t border-secondary-200 dark:border-secondary-600">
|
||||
{!sidebarCollapsed ? (
|
||||
@@ -544,26 +627,30 @@ const Layout = ({ children }) => {
|
||||
<Link
|
||||
to="/profile"
|
||||
className={`flex-1 min-w-0 rounded-md p-2 transition-all duration-200 ${
|
||||
isActive('/profile')
|
||||
? 'bg-primary-50 dark:bg-primary-600'
|
||||
: 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
isActive("/profile")
|
||||
? "bg-primary-50 dark:bg-primary-600"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<UserCircle className={`h-5 w-5 shrink-0 ${
|
||||
isActive('/profile')
|
||||
? 'text-primary-700 dark:text-white'
|
||||
: 'text-secondary-500 dark:text-secondary-400'
|
||||
}`} />
|
||||
<UserCircle
|
||||
className={`h-5 w-5 shrink-0 ${
|
||||
isActive("/profile")
|
||||
? "text-primary-700 dark:text-white"
|
||||
: "text-secondary-500 dark:text-secondary-400"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className={`text-sm leading-6 font-semibold truncate ${
|
||||
isActive('/profile')
|
||||
? 'text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200'
|
||||
}`}>
|
||||
<span
|
||||
className={`text-sm leading-6 font-semibold truncate ${
|
||||
isActive("/profile")
|
||||
? "text-primary-700 dark:text-white"
|
||||
: "text-secondary-700 dark:text-secondary-200"
|
||||
}`}
|
||||
>
|
||||
{user?.first_name || user?.username}
|
||||
</span>
|
||||
{user?.role === 'admin' && (
|
||||
{user?.role === "admin" && (
|
||||
<span className="inline-flex items-center rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-800">
|
||||
Admin
|
||||
</span>
|
||||
@@ -584,14 +671,18 @@ const Layout = ({ children }) => {
|
||||
<div className="px-2 py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-x-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<Clock className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate">Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
|
||||
<span className="truncate">
|
||||
Updated: {formatRelativeTimeShort(stats.lastUpdated)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0 disabled:opacity-50"
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{versionInfo && (
|
||||
<span className="text-xs text-secondary-400 dark:text-secondary-500 flex-shrink-0">
|
||||
@@ -607,9 +698,9 @@ const Layout = ({ children }) => {
|
||||
<Link
|
||||
to="/profile"
|
||||
className={`flex items-center justify-center p-2 rounded-md transition-colors ${
|
||||
isActive('/profile')
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
isActive("/profile")
|
||||
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
|
||||
: "text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
}`}
|
||||
title={`My Profile (${user?.username})`}
|
||||
>
|
||||
@@ -631,7 +722,9 @@ const Layout = ({ children }) => {
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded disabled:opacity-50"
|
||||
title={`Refresh data - Updated: ${formatRelativeTimeShort(stats.lastUpdated)}`}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{versionInfo && (
|
||||
<span className="text-xs text-secondary-400 dark:text-secondary-500 mt-1">
|
||||
@@ -647,9 +740,11 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className={`flex flex-col min-h-screen transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-64'
|
||||
}`}>
|
||||
<div
|
||||
className={`flex flex-col min-h-screen transition-all duration-300 ${
|
||||
sidebarCollapsed ? "lg:pl-16" : "lg:pl-64"
|
||||
}`}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
|
||||
<button
|
||||
@@ -717,13 +812,11 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
|
||||
<main className="flex-1 py-6 bg-secondary-50 dark:bg-secondary-800">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</div>
|
||||
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout
|
||||
export default Layout;
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import React from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => {
|
||||
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth()
|
||||
const ProtectedRoute = ({
|
||||
children,
|
||||
requireAdmin = false,
|
||||
requirePermission = null,
|
||||
}) => {
|
||||
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated()) {
|
||||
return <Navigate to="/login" replace />
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Check admin requirement
|
||||
@@ -22,11 +26,15 @@ const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = nu
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
|
||||
<p className="text-secondary-600">You don't have permission to access this page.</p>
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-2">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="text-secondary-600">
|
||||
You don't have permission to access this page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check specific permission requirement
|
||||
@@ -34,14 +42,18 @@ const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = nu
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
|
||||
<p className="text-secondary-600">You don't have permission to access this page.</p>
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-2">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="text-secondary-600">
|
||||
You don't have permission to access this page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute
|
||||
export default ProtectedRoute;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React from 'react'
|
||||
import { ArrowUpCircle } from 'lucide-react'
|
||||
import { ArrowUpCircle } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
|
||||
if (!show) return null
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<ArrowUpCircle
|
||||
className={`${className} text-red-500 animate-pulse`}
|
||||
title="Update available"
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradeNotificationIcon
|
||||
export default UpgradeNotificationIcon;
|
||||
|
||||
@@ -1,266 +1,275 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const AuthContext = createContext()
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null)
|
||||
const [token, setToken] = useState(null)
|
||||
const [permissions, setPermissions] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [permissionsLoading, setPermissionsLoading] = useState(false)
|
||||
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false)
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setToken] = useState(null);
|
||||
const [permissions, setPermissions] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [permissionsLoading, setPermissionsLoading] = useState(false);
|
||||
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false);
|
||||
|
||||
const [checkingSetup, setCheckingSetup] = useState(true)
|
||||
const [checkingSetup, setCheckingSetup] = useState(true);
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem('token')
|
||||
const storedUser = localStorage.getItem('user')
|
||||
const storedPermissions = localStorage.getItem('permissions')
|
||||
const storedToken = localStorage.getItem("token");
|
||||
const storedUser = localStorage.getItem("user");
|
||||
const storedPermissions = localStorage.getItem("permissions");
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
try {
|
||||
setToken(storedToken)
|
||||
setUser(JSON.parse(storedUser))
|
||||
setToken(storedToken);
|
||||
setUser(JSON.parse(storedUser));
|
||||
if (storedPermissions) {
|
||||
setPermissions(JSON.parse(storedPermissions))
|
||||
setPermissions(JSON.parse(storedPermissions));
|
||||
} else {
|
||||
// Fetch permissions if not stored
|
||||
fetchPermissions(storedToken)
|
||||
fetchPermissions(storedToken);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored user data:', error)
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('permissions')
|
||||
console.error("Error parsing stored user data:", error);
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("permissions");
|
||||
}
|
||||
}
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// Refresh permissions when user logs in (no automatic refresh)
|
||||
useEffect(() => {
|
||||
if (token && user) {
|
||||
// Only refresh permissions once when user logs in
|
||||
refreshPermissions()
|
||||
refreshPermissions();
|
||||
}
|
||||
}, [token, user])
|
||||
}, [token, user]);
|
||||
|
||||
const fetchPermissions = async (authToken) => {
|
||||
try {
|
||||
setPermissionsLoading(true)
|
||||
const response = await fetch('/api/v1/permissions/user-permissions', {
|
||||
setPermissionsLoading(true);
|
||||
const response = await fetch("/api/v1/permissions/user-permissions", {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setPermissions(data)
|
||||
localStorage.setItem('permissions', JSON.stringify(data))
|
||||
return data
|
||||
const data = await response.json();
|
||||
setPermissions(data);
|
||||
localStorage.setItem("permissions", JSON.stringify(data));
|
||||
return data;
|
||||
} else {
|
||||
console.error('Failed to fetch permissions')
|
||||
return null
|
||||
console.error("Failed to fetch permissions");
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching permissions:', error)
|
||||
return null
|
||||
console.error("Error fetching permissions:", error);
|
||||
return null;
|
||||
} finally {
|
||||
setPermissionsLoading(false)
|
||||
}
|
||||
setPermissionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshPermissions = async () => {
|
||||
if (token) {
|
||||
const updatedPermissions = await fetchPermissions(token)
|
||||
return updatedPermissions
|
||||
}
|
||||
return null
|
||||
const updatedPermissions = await fetchPermissions(token);
|
||||
return updatedPermissions;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json()
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
|
||||
// Fetch user permissions after successful login
|
||||
const userPermissions = await fetchPermissions(data.token)
|
||||
const userPermissions = await fetchPermissions(data.token);
|
||||
if (userPermissions) {
|
||||
setPermissions(userPermissions)
|
||||
setPermissions(userPermissions);
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Login failed' }
|
||||
return { success: false, error: data.error || "Login failed" };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: 'Network error occurred' }
|
||||
}
|
||||
return { success: false, error: "Network error occurred" };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
if (token) {
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
await fetch("/api/v1/auth/logout", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
console.error("Logout error:", error);
|
||||
} finally {
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setPermissions(null)
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('permissions')
|
||||
}
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setPermissions(null);
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("permissions");
|
||||
}
|
||||
};
|
||||
|
||||
const updateProfile = async (profileData) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/profile', {
|
||||
method: 'PUT',
|
||||
const response = await fetch("/api/v1/auth/profile", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(profileData),
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json()
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setUser(data.user)
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
return { success: true, user: data.user }
|
||||
setUser(data.user);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
return { success: true, user: data.user };
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Update failed' }
|
||||
return { success: false, error: data.error || "Update failed" };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: 'Network error occurred' }
|
||||
}
|
||||
return { success: false, error: "Network error occurred" };
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (currentPassword, newPassword) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/change-password', {
|
||||
method: 'PUT',
|
||||
const response = await fetch("/api/v1/auth/change-password", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json()
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true }
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Password change failed' }
|
||||
return {
|
||||
success: false,
|
||||
error: data.error || "Password change failed",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: 'Network error occurred' }
|
||||
}
|
||||
return { success: false, error: "Network error occurred" };
|
||||
}
|
||||
};
|
||||
|
||||
const isAuthenticated = () => {
|
||||
return !!(token && user)
|
||||
}
|
||||
return !!(token && user);
|
||||
};
|
||||
|
||||
const isAdmin = () => {
|
||||
return user?.role === 'admin'
|
||||
}
|
||||
return user?.role === "admin";
|
||||
};
|
||||
|
||||
// Permission checking functions
|
||||
const hasPermission = (permission) => {
|
||||
// If permissions are still loading, return false to show loading state
|
||||
if (permissionsLoading) {
|
||||
return false
|
||||
}
|
||||
return permissions?.[permission] === true
|
||||
return false;
|
||||
}
|
||||
return permissions?.[permission] === true;
|
||||
};
|
||||
|
||||
const canViewDashboard = () => hasPermission('can_view_dashboard')
|
||||
const canViewHosts = () => hasPermission('can_view_hosts')
|
||||
const canManageHosts = () => hasPermission('can_manage_hosts')
|
||||
const canViewPackages = () => hasPermission('can_view_packages')
|
||||
const canManagePackages = () => hasPermission('can_manage_packages')
|
||||
const canViewUsers = () => hasPermission('can_view_users')
|
||||
const canManageUsers = () => hasPermission('can_manage_users')
|
||||
const canViewReports = () => hasPermission('can_view_reports')
|
||||
const canExportData = () => hasPermission('can_export_data')
|
||||
const canManageSettings = () => hasPermission('can_manage_settings')
|
||||
const canViewDashboard = () => hasPermission("can_view_dashboard");
|
||||
const canViewHosts = () => hasPermission("can_view_hosts");
|
||||
const canManageHosts = () => hasPermission("can_manage_hosts");
|
||||
const canViewPackages = () => hasPermission("can_view_packages");
|
||||
const canManagePackages = () => hasPermission("can_manage_packages");
|
||||
const canViewUsers = () => hasPermission("can_view_users");
|
||||
const canManageUsers = () => hasPermission("can_manage_users");
|
||||
const canViewReports = () => hasPermission("can_view_reports");
|
||||
const canExportData = () => hasPermission("can_export_data");
|
||||
const canManageSettings = () => hasPermission("can_manage_settings");
|
||||
|
||||
// Check if any admin users exist (for first-time setup)
|
||||
const checkAdminUsersExist = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/check-admin-users', {
|
||||
method: 'GET',
|
||||
const response = await fetch("/api/v1/auth/check-admin-users", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setNeedsFirstTimeSetup(!data.hasAdminUsers)
|
||||
const data = await response.json();
|
||||
setNeedsFirstTimeSetup(!data.hasAdminUsers);
|
||||
} else {
|
||||
// If endpoint doesn't exist or fails, assume setup is needed
|
||||
setNeedsFirstTimeSetup(true)
|
||||
setNeedsFirstTimeSetup(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking admin users:', error)
|
||||
console.error("Error checking admin users:", error);
|
||||
// If there's an error, assume setup is needed
|
||||
setNeedsFirstTimeSetup(true)
|
||||
setNeedsFirstTimeSetup(true);
|
||||
} finally {
|
||||
setCheckingSetup(false)
|
||||
setCheckingSetup(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// Check for admin users on initial load
|
||||
useEffect(() => {
|
||||
if (!token && !user) {
|
||||
checkAdminUsersExist()
|
||||
checkAdminUsersExist();
|
||||
} else {
|
||||
setCheckingSetup(false)
|
||||
setCheckingSetup(false);
|
||||
}
|
||||
}, [token, user, checkAdminUsersExist])
|
||||
}, [token, user, checkAdminUsersExist]);
|
||||
|
||||
const setAuthState = (authToken, authUser) => {
|
||||
setToken(authToken)
|
||||
setUser(authUser)
|
||||
localStorage.setItem('token', authToken)
|
||||
localStorage.setItem('user', JSON.stringify(authUser))
|
||||
}
|
||||
setToken(authToken);
|
||||
setUser(authUser);
|
||||
localStorage.setItem("token", authToken);
|
||||
localStorage.setItem("user", JSON.stringify(authUser));
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
@@ -287,12 +296,8 @@ export const AuthProvider = ({ children }) => {
|
||||
canManageUsers,
|
||||
canViewReports,
|
||||
canExportData,
|
||||
canManageSettings
|
||||
}
|
||||
canManageSettings,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -1,54 +1,52 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
const ThemeContext = createContext()
|
||||
const ThemeContext = createContext();
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext)
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ThemeProvider = ({ children }) => {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
// Check localStorage first, then system preference
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
if (savedTheme) {
|
||||
return savedTheme
|
||||
return savedTheme;
|
||||
}
|
||||
// Check system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark'
|
||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
return "dark";
|
||||
}
|
||||
return 'light'
|
||||
})
|
||||
return "light";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Apply theme to document
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
toggleTheme,
|
||||
isDark: theme === 'dark'
|
||||
}
|
||||
isDark: theme === "dark",
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,58 +1,64 @@
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { versionAPI, settingsAPI } from '../utils/api'
|
||||
import { useAuth } from './AuthContext'
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
import { settingsAPI, versionAPI } from "../utils/api";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
const UpdateNotificationContext = createContext()
|
||||
const UpdateNotificationContext = createContext();
|
||||
|
||||
export const useUpdateNotification = () => {
|
||||
const context = useContext(UpdateNotificationContext)
|
||||
const context = useContext(UpdateNotificationContext);
|
||||
if (!context) {
|
||||
throw new Error('useUpdateNotification must be used within an UpdateNotificationProvider')
|
||||
throw new Error(
|
||||
"useUpdateNotification must be used within an UpdateNotificationProvider",
|
||||
);
|
||||
}
|
||||
return context
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const UpdateNotificationProvider = ({ children }) => {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const { user, token } = useAuth()
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const { user, token } = useAuth();
|
||||
|
||||
// Ensure settings are loaded
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => settingsAPI.get().then(res => res.data),
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
enabled: !!(user && token),
|
||||
retry: 1
|
||||
})
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
// Query for update information
|
||||
const { data: updateData, isLoading, error } = useQuery({
|
||||
queryKey: ['updateCheck'],
|
||||
queryFn: () => versionAPI.checkUpdates().then(res => res.data),
|
||||
const {
|
||||
data: updateData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["updateCheck"],
|
||||
queryFn: () => versionAPI.checkUpdates().then((res) => res.data),
|
||||
staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
retry: 1,
|
||||
enabled: !!(user && token && settings && !settingsLoading) // Only run when authenticated and settings are loaded
|
||||
})
|
||||
enabled: !!(user && token && settings && !settingsLoading), // Only run when authenticated and settings are loaded
|
||||
});
|
||||
|
||||
const updateAvailable = updateData?.isUpdateAvailable && !dismissed
|
||||
const updateInfo = updateData
|
||||
const updateAvailable = updateData?.isUpdateAvailable && !dismissed;
|
||||
const updateInfo = updateData;
|
||||
|
||||
const dismissNotification = () => {
|
||||
setDismissed(true)
|
||||
}
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const value = {
|
||||
updateAvailable,
|
||||
updateInfo,
|
||||
dismissNotification,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
error,
|
||||
};
|
||||
|
||||
return (
|
||||
<UpdateNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</UpdateNotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.jsx";
|
||||
import "./index.css";
|
||||
|
||||
// Create a client for React Query
|
||||
const queryClient = new QueryClient({
|
||||
@@ -14,9 +14,9 @@ const queryClient = new QueryClient({
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -24,4 +24,4 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,97 +1,101 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Server,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
CheckCircle
|
||||
} from 'lucide-react'
|
||||
import { hostGroupsAPI } from '../utils/api'
|
||||
CheckCircle,
|
||||
Edit,
|
||||
Plus,
|
||||
Server,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { hostGroupsAPI } from "../utils/api";
|
||||
|
||||
const HostGroups = () => {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [selectedGroup, setSelectedGroup] = useState(null)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [groupToDelete, setGroupToDelete] = useState(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [groupToDelete, setGroupToDelete] = useState(null);
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch host groups
|
||||
const { data: hostGroups, isLoading, error } = useQuery({
|
||||
queryKey: ['hostGroups'],
|
||||
queryFn: () => hostGroupsAPI.list().then(res => res.data),
|
||||
})
|
||||
const {
|
||||
data: hostGroups,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["hostGroups"],
|
||||
queryFn: () => hostGroupsAPI.list().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Create host group mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => hostGroupsAPI.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowCreateModal(false)
|
||||
queryClient.invalidateQueries(["hostGroups"]);
|
||||
setShowCreateModal(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create host group:', error)
|
||||
}
|
||||
})
|
||||
console.error("Failed to create host group:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Update host group mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
queryClient.invalidateQueries(["hostGroups"]);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update host group:', error)
|
||||
}
|
||||
})
|
||||
console.error("Failed to update host group:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Delete host group mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => hostGroupsAPI.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
queryClient.invalidateQueries(["hostGroups"]);
|
||||
setShowDeleteModal(false);
|
||||
setGroupToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete host group:', error)
|
||||
}
|
||||
})
|
||||
console.error("Failed to delete host group:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = (data) => {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleEdit = (group) => {
|
||||
setSelectedGroup(group)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
setSelectedGroup(group);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleUpdate = (data) => {
|
||||
updateMutation.mutate({ id: selectedGroup.id, data })
|
||||
}
|
||||
updateMutation.mutate({ id: selectedGroup.id, data });
|
||||
};
|
||||
|
||||
const handleDeleteClick = (group) => {
|
||||
setGroupToDelete(group)
|
||||
setShowDeleteModal(true)
|
||||
}
|
||||
setGroupToDelete(group);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
deleteMutation.mutate(groupToDelete.id)
|
||||
}
|
||||
deleteMutation.mutate(groupToDelete.id);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -104,12 +108,12 @@ const HostGroups = () => {
|
||||
Error loading host groups
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || 'Failed to load host groups'}
|
||||
{error.message || "Failed to load host groups"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -134,7 +138,10 @@ const HostGroups = () => {
|
||||
{hostGroups && hostGroups.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{hostGroups.map((group) => (
|
||||
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
|
||||
<div
|
||||
key={group.id}
|
||||
className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -173,7 +180,10 @@ const HostGroups = () => {
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<div className="flex items-center gap-1">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
|
||||
<span>
|
||||
{group._count.hosts} host
|
||||
{group._count.hosts !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,8 +222,8 @@ const HostGroups = () => {
|
||||
<EditHostGroupModal
|
||||
group={selectedGroup}
|
||||
onClose={() => {
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
isLoading={updateMutation.isPending}
|
||||
@@ -225,36 +235,36 @@ const HostGroups = () => {
|
||||
<DeleteHostGroupModal
|
||||
group={groupToDelete}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
setShowDeleteModal(false);
|
||||
setGroupToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Create Host Group Modal
|
||||
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#3B82F6'
|
||||
})
|
||||
name: "",
|
||||
description: "",
|
||||
color: "#3B82F6",
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
@@ -324,39 +334,35 @@ const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Group'}
|
||||
<button type="submit" className="btn-primary" disabled={isLoading}>
|
||||
{isLoading ? "Creating..." : "Create Group"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Edit Host Group Modal
|
||||
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: group.name,
|
||||
description: group.description || '',
|
||||
color: group.color || '#3B82F6'
|
||||
})
|
||||
description: group.description || "",
|
||||
color: group.color || "#3B82F6",
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
@@ -426,19 +432,15 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Group'}
|
||||
<button type="submit" className="btn-primary" disabled={isLoading}>
|
||||
{isLoading ? "Updating..." : "Update Group"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Delete Confirmation Modal
|
||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
@@ -461,13 +463,14 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-200">
|
||||
Are you sure you want to delete the host group{' '}
|
||||
Are you sure you want to delete the host group{" "}
|
||||
<span className="font-semibold">"{group.name}"</span>?
|
||||
</p>
|
||||
{group._count.hosts > 0 && (
|
||||
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||
<p className="text-sm text-warning-800">
|
||||
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
|
||||
<strong>Warning:</strong> This group contains{" "}
|
||||
{group._count.hosts} host{group._count.hosts !== 1 ? "s" : ""}.
|
||||
You must move or remove these hosts before deleting the group.
|
||||
</p>
|
||||
</div>
|
||||
@@ -487,12 +490,12 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
className="btn-danger"
|
||||
disabled={isLoading || group._count.hosts > 0}
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete Group'}
|
||||
{isLoading ? "Deleting..." : "Delete Group"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default HostGroups
|
||||
export default HostGroups;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,173 +1,193 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft, Mail } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { authAPI } from '../utils/api'
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
Mail,
|
||||
Smartphone,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { authAPI } from "../utils/api";
|
||||
|
||||
const Login = () => {
|
||||
const [isSignupMode, setIsSignupMode] = useState(false)
|
||||
const [isSignupMode, setIsSignupMode] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: ''
|
||||
})
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
});
|
||||
const [tfaData, setTfaData] = useState({
|
||||
token: ''
|
||||
})
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [requiresTfa, setRequiresTfa] = useState(false)
|
||||
const [tfaUsername, setTfaUsername] = useState('')
|
||||
const [signupEnabled, setSignupEnabled] = useState(false)
|
||||
token: "",
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [requiresTfa, setRequiresTfa] = useState(false);
|
||||
const [tfaUsername, setTfaUsername] = useState("");
|
||||
const [signupEnabled, setSignupEnabled] = useState(false);
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { login, setAuthState } = useAuth()
|
||||
const navigate = useNavigate();
|
||||
const { login, setAuthState } = useAuth();
|
||||
|
||||
// Check if signup is enabled
|
||||
useEffect(() => {
|
||||
const checkSignupEnabled = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/signup-enabled')
|
||||
const response = await fetch("/api/v1/auth/signup-enabled");
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setSignupEnabled(data.signupEnabled)
|
||||
const data = await response.json();
|
||||
setSignupEnabled(data.signupEnabled);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check signup status:', error)
|
||||
console.error("Failed to check signup status:", error);
|
||||
// Default to disabled on error for security
|
||||
setSignupEnabled(false)
|
||||
setSignupEnabled(false);
|
||||
}
|
||||
}
|
||||
checkSignupEnabled()
|
||||
}, [])
|
||||
};
|
||||
checkSignupEnabled();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await authAPI.login(formData.username, formData.password)
|
||||
const response = await authAPI.login(
|
||||
formData.username,
|
||||
formData.password,
|
||||
);
|
||||
|
||||
if (response.data.requiresTfa) {
|
||||
setRequiresTfa(true)
|
||||
setTfaUsername(formData.username)
|
||||
setError('')
|
||||
setRequiresTfa(true);
|
||||
setTfaUsername(formData.username);
|
||||
setError("");
|
||||
} else {
|
||||
// Regular login successful
|
||||
const result = await login(formData.username, formData.password)
|
||||
const result = await login(formData.username, formData.password);
|
||||
if (result.success) {
|
||||
navigate('/')
|
||||
navigate("/");
|
||||
} else {
|
||||
setError(result.error || 'Login failed')
|
||||
setError(result.error || "Login failed");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Login failed')
|
||||
setError(err.response?.data?.error || "Login failed");
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await authAPI.signup(formData.username, formData.email, formData.password, formData.firstName, formData.lastName)
|
||||
const response = await authAPI.signup(
|
||||
formData.username,
|
||||
formData.email,
|
||||
formData.password,
|
||||
formData.firstName,
|
||||
formData.lastName,
|
||||
);
|
||||
if (response.data && response.data.token) {
|
||||
// Update AuthContext state and localStorage
|
||||
setAuthState(response.data.token, response.data.user)
|
||||
setAuthState(response.data.token, response.data.user);
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/')
|
||||
navigate("/");
|
||||
} else {
|
||||
setError('Signup failed - invalid response')
|
||||
setError("Signup failed - invalid response");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Signup error:', err)
|
||||
const errorMessage = err.response?.data?.error ||
|
||||
console.error("Signup error:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.error ||
|
||||
(err.response?.data?.errors && err.response.data.errors.length > 0
|
||||
? err.response.data.errors.map(e => e.msg).join(', ')
|
||||
: err.message || 'Signup failed')
|
||||
setError(errorMessage)
|
||||
? err.response.data.errors.map((e) => e.msg).join(", ")
|
||||
: err.message || "Signup failed");
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTfaSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token)
|
||||
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token);
|
||||
|
||||
if (response.data && response.data.token) {
|
||||
// Store token and user data
|
||||
localStorage.setItem('token', response.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user))
|
||||
localStorage.setItem("token", response.data.token);
|
||||
localStorage.setItem("user", JSON.stringify(response.data.user));
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/')
|
||||
navigate("/");
|
||||
} else {
|
||||
setError('TFA verification failed - invalid response')
|
||||
setError("TFA verification failed - invalid response");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('TFA verification error:', err)
|
||||
const errorMessage = err.response?.data?.error || err.message || 'TFA verification failed'
|
||||
setError(errorMessage)
|
||||
console.error("TFA verification error:", err);
|
||||
const errorMessage =
|
||||
err.response?.data?.error || err.message || "TFA verification failed";
|
||||
setError(errorMessage);
|
||||
// Clear the token input for security
|
||||
setTfaData({ token: '' })
|
||||
setTfaData({ token: "" });
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTfaInputChange = (e) => {
|
||||
setTfaData({
|
||||
...tfaData,
|
||||
[e.target.name]: e.target.value.replace(/\D/g, '').slice(0, 6)
|
||||
})
|
||||
[e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6),
|
||||
});
|
||||
// Clear error when user starts typing
|
||||
if (error) {
|
||||
setError('')
|
||||
}
|
||||
setError("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setRequiresTfa(false)
|
||||
setTfaData({ token: '' })
|
||||
setError('')
|
||||
}
|
||||
setRequiresTfa(false);
|
||||
setTfaData({ token: "" });
|
||||
setError("");
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
// Only allow signup mode if signup is enabled
|
||||
if (!signupEnabled && !isSignupMode) {
|
||||
return // Don't allow switching to signup if disabled
|
||||
return; // Don't allow switching to signup if disabled
|
||||
}
|
||||
setIsSignupMode(!isSignupMode)
|
||||
setIsSignupMode(!isSignupMode);
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: ''
|
||||
})
|
||||
setError('')
|
||||
}
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
});
|
||||
setError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
@@ -177,7 +197,7 @@ const Login = () => {
|
||||
<Lock size={24} color="#2563eb" strokeWidth={2} />
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
|
||||
{isSignupMode ? 'Create PatchMon Account' : 'Sign in to PatchMon'}
|
||||
{isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-secondary-600">
|
||||
Monitor and manage your Linux package updates
|
||||
@@ -185,11 +205,17 @@ const Login = () => {
|
||||
</div>
|
||||
|
||||
{!requiresTfa ? (
|
||||
<form className="mt-8 space-y-6" onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}>
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
|
||||
{isSignupMode ? 'Username' : 'Username or Email'}
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
{isSignupMode ? "Username" : "Username or Email"}
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
@@ -200,14 +226,14 @@ const Login = () => {
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder={isSignupMode ? "Enter your username" : "Enter your username or email"}
|
||||
placeholder={
|
||||
isSignupMode
|
||||
? "Enter your username"
|
||||
: "Enter your username or email"
|
||||
}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<User
|
||||
size={20}
|
||||
color="#64748b"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<User size={20} color="#64748b" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,7 +242,10 @@ const Login = () => {
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-secondary-700">
|
||||
<label
|
||||
htmlFor="firstName"
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
First Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
@@ -236,7 +265,10 @@ const Login = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700">
|
||||
<label
|
||||
htmlFor="lastName"
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
Last Name
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
@@ -257,7 +289,10 @@ const Login = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-secondary-700">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
@@ -272,11 +307,7 @@ const Login = () => {
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<Mail
|
||||
size={20}
|
||||
color="#64748b"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Mail size={20} color="#64748b" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,14 +315,17 @@ const Login = () => {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-secondary-700">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
@@ -299,11 +333,7 @@ const Login = () => {
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||
<Lock
|
||||
size={20}
|
||||
color="#64748b"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Lock size={20} color="#64748b" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
|
||||
<button
|
||||
@@ -342,10 +372,12 @@ const Login = () => {
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{isSignupMode ? 'Creating account...' : 'Signing in...'}
|
||||
{isSignupMode ? "Creating account..." : "Signing in..."}
|
||||
</div>
|
||||
) : isSignupMode ? (
|
||||
"Create Account"
|
||||
) : (
|
||||
isSignupMode ? 'Create Account' : 'Sign in'
|
||||
"Sign in"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -353,13 +385,15 @@ const Login = () => {
|
||||
{signupEnabled && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
{isSignupMode ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||
{isSignupMode
|
||||
? "Already have an account?"
|
||||
: "Don't have an account?"}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
|
||||
>
|
||||
{isSignupMode ? 'Sign in' : 'Sign up'}
|
||||
{isSignupMode ? "Sign in" : "Sign up"}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
@@ -380,7 +414,10 @@ const Login = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="token" className="block text-sm font-medium text-secondary-700">
|
||||
<label
|
||||
htmlFor="token"
|
||||
className="block text-sm font-medium text-secondary-700"
|
||||
>
|
||||
Verification Code
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
@@ -421,7 +458,7 @@ const Login = () => {
|
||||
Verifying...
|
||||
</div>
|
||||
) : (
|
||||
'Verify Code'
|
||||
"Verify Code"
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -444,7 +481,7 @@ const Login = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Login
|
||||
export default Login;
|
||||
|
||||
@@ -1,98 +1,107 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Server,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
import { hostGroupsAPI } from '../utils/api'
|
||||
Edit,
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { hostGroupsAPI } from "../utils/api";
|
||||
|
||||
const Options = () => {
|
||||
const [activeTab, setActiveTab] = useState('hostgroups')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [selectedGroup, setSelectedGroup] = useState(null)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [groupToDelete, setGroupToDelete] = useState(null)
|
||||
const [activeTab, setActiveTab] = useState("hostgroups");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState(null);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [groupToDelete, setGroupToDelete] = useState(null);
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{ id: 'hostgroups', name: 'Host Groups', icon: Users },
|
||||
{ id: 'notifications', name: 'Notifications', icon: AlertTriangle, comingSoon: true }
|
||||
]
|
||||
{ id: "hostgroups", name: "Host Groups", icon: Users },
|
||||
{
|
||||
id: "notifications",
|
||||
name: "Notifications",
|
||||
icon: AlertTriangle,
|
||||
comingSoon: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Fetch host groups
|
||||
const { data: hostGroups, isLoading, error } = useQuery({
|
||||
queryKey: ['hostGroups'],
|
||||
queryFn: () => hostGroupsAPI.list().then(res => res.data),
|
||||
})
|
||||
const {
|
||||
data: hostGroups,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["hostGroups"],
|
||||
queryFn: () => hostGroupsAPI.list().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Create host group mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => hostGroupsAPI.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowCreateModal(false)
|
||||
queryClient.invalidateQueries(["hostGroups"]);
|
||||
setShowCreateModal(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create host group:', error)
|
||||
}
|
||||
})
|
||||
console.error("Failed to create host group:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Update host group mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
queryClient.invalidateQueries(["hostGroups"]);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update host group:', error)
|
||||
}
|
||||
})
|
||||
console.error("Failed to update host group:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Delete host group mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => hostGroupsAPI.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
queryClient.invalidateQueries(["hostGroups"]);
|
||||
setShowDeleteModal(false);
|
||||
setGroupToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete host group:', error)
|
||||
}
|
||||
})
|
||||
console.error("Failed to delete host group:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = (data) => {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleEdit = (group) => {
|
||||
setSelectedGroup(group)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
setSelectedGroup(group);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleUpdate = (data) => {
|
||||
updateMutation.mutate({ id: selectedGroup.id, data })
|
||||
}
|
||||
updateMutation.mutate({ id: selectedGroup.id, data });
|
||||
};
|
||||
|
||||
const handleDeleteClick = (group) => {
|
||||
setGroupToDelete(group)
|
||||
setShowDeleteModal(true)
|
||||
}
|
||||
setGroupToDelete(group);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
deleteMutation.mutate(groupToDelete.id)
|
||||
}
|
||||
deleteMutation.mutate(groupToDelete.id);
|
||||
};
|
||||
|
||||
const renderHostGroupsTab = () => {
|
||||
if (isLoading) {
|
||||
@@ -100,7 +109,7 @@ const Options = () => {
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -113,12 +122,12 @@ const Options = () => {
|
||||
Error loading host groups
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || 'Failed to load host groups'}
|
||||
{error.message || "Failed to load host groups"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -146,7 +155,10 @@ const Options = () => {
|
||||
{hostGroups && hostGroups.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{hostGroups.map((group) => (
|
||||
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
|
||||
<div
|
||||
key={group.id}
|
||||
className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -185,7 +197,10 @@ const Options = () => {
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<div className="flex items-center gap-1">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
|
||||
<span>
|
||||
{group._count.hosts} host
|
||||
{group._count.hosts !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,8 +225,8 @@ const Options = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renderComingSoonTab = (tabName) => (
|
||||
<div className="text-center py-12">
|
||||
@@ -220,10 +235,11 @@ const Options = () => {
|
||||
{tabName} Coming Soon
|
||||
</h3>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
This feature is currently under development and will be available in a future update.
|
||||
This feature is currently under development and will be available in a
|
||||
future update.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -241,15 +257,15 @@ const Options = () => {
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300'
|
||||
? "border-primary-500 text-primary-600 dark:text-primary-400"
|
||||
: "border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
@@ -260,15 +276,15 @@ const Options = () => {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="mt-6">
|
||||
{activeTab === 'hostgroups' && renderHostGroupsTab()}
|
||||
{activeTab === 'notifications' && renderComingSoonTab('Notifications')}
|
||||
{activeTab === "hostgroups" && renderHostGroupsTab()}
|
||||
{activeTab === "notifications" && renderComingSoonTab("Notifications")}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
@@ -285,8 +301,8 @@ const Options = () => {
|
||||
<EditHostGroupModal
|
||||
group={selectedGroup}
|
||||
onClose={() => {
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
isLoading={updateMutation.isPending}
|
||||
@@ -298,36 +314,36 @@ const Options = () => {
|
||||
<DeleteHostGroupModal
|
||||
group={groupToDelete}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
setShowDeleteModal(false);
|
||||
setGroupToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Create Host Group Modal
|
||||
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#3B82F6'
|
||||
})
|
||||
name: "",
|
||||
description: "",
|
||||
color: "#3B82F6",
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
@@ -397,39 +413,35 @@ const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Group'}
|
||||
<button type="submit" className="btn-primary" disabled={isLoading}>
|
||||
{isLoading ? "Creating..." : "Create Group"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Edit Host Group Modal
|
||||
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: group.name,
|
||||
description: group.description || '',
|
||||
color: group.color || '#3B82F6'
|
||||
})
|
||||
description: group.description || "",
|
||||
color: group.color || "#3B82F6",
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
@@ -499,19 +511,15 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Group'}
|
||||
<button type="submit" className="btn-primary" disabled={isLoading}>
|
||||
{isLoading ? "Updating..." : "Update Group"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Delete Confirmation Modal
|
||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
@@ -534,13 +542,14 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-200">
|
||||
Are you sure you want to delete the host group{' '}
|
||||
Are you sure you want to delete the host group{" "}
|
||||
<span className="font-semibold">"{group.name}"</span>?
|
||||
</p>
|
||||
{group._count.hosts > 0 && (
|
||||
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||
<p className="text-sm text-warning-800">
|
||||
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
|
||||
<strong>Warning:</strong> This group contains{" "}
|
||||
{group._count.hosts} host{group._count.hosts !== 1 ? "s" : ""}.
|
||||
You must move or remove these hosts before deleting the group.
|
||||
</p>
|
||||
</div>
|
||||
@@ -560,12 +569,12 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
className="btn-danger"
|
||||
disabled={isLoading || group._count.hosts > 0}
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete Group'}
|
||||
{isLoading ? "Deleting..." : "Delete Group"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Options
|
||||
export default Options;
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import React from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Package } from 'lucide-react'
|
||||
import { Package } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const PackageDetail = () => {
|
||||
const { packageId } = useParams()
|
||||
const { packageId } = useParams();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
<div className="card p-8 text-center">
|
||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 mb-2">Package Details</h3>
|
||||
<h3 className="text-lg font-medium text-secondary-900 mb-2">
|
||||
Package Details
|
||||
</h3>
|
||||
<p className="text-secondary-600">
|
||||
Detailed view for package: {packageId}
|
||||
</p>
|
||||
<p className="text-secondary-600 mt-2">
|
||||
This page will show package information, affected hosts, version distribution, and more.
|
||||
This page will show package information, affected hosts, version
|
||||
distribution, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default PackageDetail
|
||||
export default PackageDetail;
|
||||
|
||||
@@ -1,227 +1,252 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
Columns,
|
||||
ExternalLink,
|
||||
Eye as EyeIcon,
|
||||
EyeOff as EyeOffIcon,
|
||||
Filter,
|
||||
GripVertical,
|
||||
Package,
|
||||
Server,
|
||||
Shield,
|
||||
RefreshCw,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
Filter,
|
||||
ExternalLink,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ChevronDown,
|
||||
Server,
|
||||
Settings,
|
||||
Columns,
|
||||
GripVertical,
|
||||
Shield,
|
||||
X,
|
||||
Eye as EyeIcon,
|
||||
EyeOff as EyeOffIcon
|
||||
} from 'lucide-react'
|
||||
import { dashboardAPI } from '../utils/api'
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { dashboardAPI } from "../utils/api";
|
||||
|
||||
const Packages = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('all')
|
||||
const [securityFilter, setSecurityFilter] = useState('all')
|
||||
const [hostFilter, setHostFilter] = useState('all')
|
||||
const [sortField, setSortField] = useState('name')
|
||||
const [sortDirection, setSortDirection] = useState('asc')
|
||||
const [showColumnSettings, setShowColumnSettings] = useState(false)
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [securityFilter, setSecurityFilter] = useState("all");
|
||||
const [hostFilter, setHostFilter] = useState("all");
|
||||
const [sortField, setSortField] = useState("name");
|
||||
const [sortDirection, setSortDirection] = useState("asc");
|
||||
const [showColumnSettings, setShowColumnSettings] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Handle host filter from URL parameter
|
||||
useEffect(() => {
|
||||
const hostParam = searchParams.get('host')
|
||||
const hostParam = searchParams.get("host");
|
||||
if (hostParam) {
|
||||
setHostFilter(hostParam)
|
||||
setHostFilter(hostParam);
|
||||
}
|
||||
}, [searchParams])
|
||||
}, [searchParams]);
|
||||
|
||||
// Column configuration
|
||||
const [columnConfig, setColumnConfig] = useState(() => {
|
||||
const defaultConfig = [
|
||||
{ id: 'name', label: 'Package', visible: true, order: 0 },
|
||||
{ id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
|
||||
{ id: 'priority', label: 'Priority', visible: true, order: 2 },
|
||||
{ id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
|
||||
]
|
||||
{ id: "name", label: "Package", visible: true, order: 0 },
|
||||
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
|
||||
{ id: "priority", label: "Priority", visible: true, order: 2 },
|
||||
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
|
||||
];
|
||||
|
||||
const saved = localStorage.getItem('packages-column-config')
|
||||
const saved = localStorage.getItem("packages-column-config");
|
||||
if (saved) {
|
||||
const savedConfig = JSON.parse(saved)
|
||||
const savedConfig = JSON.parse(saved);
|
||||
// Merge with defaults to handle new columns
|
||||
return defaultConfig.map(defaultCol => {
|
||||
const savedCol = savedConfig.find(col => col.id === defaultCol.id)
|
||||
return savedCol ? { ...defaultCol, ...savedCol } : defaultCol
|
||||
})
|
||||
return defaultConfig.map((defaultCol) => {
|
||||
const savedCol = savedConfig.find((col) => col.id === defaultCol.id);
|
||||
return savedCol ? { ...defaultCol, ...savedCol } : defaultCol;
|
||||
});
|
||||
}
|
||||
return defaultConfig
|
||||
})
|
||||
return defaultConfig;
|
||||
});
|
||||
|
||||
// Update column configuration
|
||||
const updateColumnConfig = (newConfig) => {
|
||||
setColumnConfig(newConfig)
|
||||
localStorage.setItem('packages-column-config', JSON.stringify(newConfig))
|
||||
}
|
||||
setColumnConfig(newConfig);
|
||||
localStorage.setItem("packages-column-config", JSON.stringify(newConfig));
|
||||
};
|
||||
|
||||
// Handle affected hosts click
|
||||
const handleAffectedHostsClick = (pkg) => {
|
||||
const affectedHosts = pkg.affectedHosts || []
|
||||
const hostIds = affectedHosts.map(host => host.hostId)
|
||||
const hostNames = affectedHosts.map(host => host.friendlyName)
|
||||
const affectedHosts = pkg.affectedHosts || [];
|
||||
const hostIds = affectedHosts.map((host) => host.hostId);
|
||||
const hostNames = affectedHosts.map((host) => host.friendlyName);
|
||||
|
||||
// Create URL with selected hosts and filter
|
||||
const params = new URLSearchParams()
|
||||
params.set('selected', hostIds.join(','))
|
||||
params.set('filter', 'selected')
|
||||
const params = new URLSearchParams();
|
||||
params.set("selected", hostIds.join(","));
|
||||
params.set("filter", "selected");
|
||||
|
||||
// Navigate to hosts page with selected hosts
|
||||
navigate(`/hosts?${params.toString()}`)
|
||||
}
|
||||
navigate(`/hosts?${params.toString()}`);
|
||||
};
|
||||
|
||||
// Handle URL filter parameters
|
||||
useEffect(() => {
|
||||
const filter = searchParams.get('filter')
|
||||
if (filter === 'outdated') {
|
||||
const filter = searchParams.get("filter");
|
||||
if (filter === "outdated") {
|
||||
// For outdated packages, we want to show all packages that need updates
|
||||
// This is the default behavior, so we don't need to change filters
|
||||
setCategoryFilter('all')
|
||||
setSecurityFilter('all')
|
||||
} else if (filter === 'security') {
|
||||
setCategoryFilter("all");
|
||||
setSecurityFilter("all");
|
||||
} else if (filter === "security") {
|
||||
// For security updates, filter to show only security updates
|
||||
setSecurityFilter('security')
|
||||
setCategoryFilter('all')
|
||||
setSecurityFilter("security");
|
||||
setCategoryFilter("all");
|
||||
}
|
||||
}, [searchParams])
|
||||
}, [searchParams]);
|
||||
|
||||
const { data: packages, isLoading, error, refetch, isFetching } = useQuery({
|
||||
queryKey: ['packages'],
|
||||
queryFn: () => dashboardAPI.getPackages().then(res => res.data),
|
||||
const {
|
||||
data: packages,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["packages"],
|
||||
queryFn: () => dashboardAPI.getPackages().then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch hosts data to get total packages count
|
||||
const { data: hosts } = useQuery({
|
||||
queryKey: ['hosts'],
|
||||
queryFn: () => dashboardAPI.getHosts().then(res => res.data),
|
||||
queryKey: ["hosts"],
|
||||
queryFn: () => dashboardAPI.getHosts().then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
})
|
||||
});
|
||||
|
||||
// Filter and sort packages
|
||||
const filteredAndSortedPackages = useMemo(() => {
|
||||
if (!packages) return []
|
||||
if (!packages) return [];
|
||||
|
||||
// Filter packages
|
||||
const filtered = packages.filter(pkg => {
|
||||
const matchesSearch = pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(pkg.description && pkg.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
const filtered = packages.filter((pkg) => {
|
||||
const matchesSearch =
|
||||
pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(pkg.description &&
|
||||
pkg.description.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
|
||||
const matchesCategory =
|
||||
categoryFilter === "all" || pkg.category === categoryFilter;
|
||||
|
||||
const matchesSecurity = securityFilter === 'all' ||
|
||||
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
|
||||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
|
||||
const matchesSecurity =
|
||||
securityFilter === "all" ||
|
||||
(securityFilter === "security" && pkg.isSecurityUpdate) ||
|
||||
(securityFilter === "regular" && !pkg.isSecurityUpdate);
|
||||
|
||||
const affectedHosts = pkg.affectedHosts || []
|
||||
const matchesHost = hostFilter === 'all' ||
|
||||
affectedHosts.some(host => host.hostId === hostFilter)
|
||||
const affectedHosts = pkg.affectedHosts || [];
|
||||
const matchesHost =
|
||||
hostFilter === "all" ||
|
||||
affectedHosts.some((host) => host.hostId === hostFilter);
|
||||
|
||||
return matchesSearch && matchesCategory && matchesSecurity && matchesHost
|
||||
})
|
||||
return matchesSearch && matchesCategory && matchesSecurity && matchesHost;
|
||||
});
|
||||
|
||||
// Sorting
|
||||
filtered.sort((a, b) => {
|
||||
let aValue, bValue
|
||||
let aValue, bValue;
|
||||
|
||||
switch (sortField) {
|
||||
case 'name':
|
||||
aValue = a.name?.toLowerCase() || ''
|
||||
bValue = b.name?.toLowerCase() || ''
|
||||
break
|
||||
case 'latestVersion':
|
||||
aValue = a.latestVersion?.toLowerCase() || ''
|
||||
bValue = b.latestVersion?.toLowerCase() || ''
|
||||
break
|
||||
case 'affectedHosts':
|
||||
aValue = a.affectedHostsCount || a.affectedHosts?.length || 0
|
||||
bValue = b.affectedHostsCount || b.affectedHosts?.length || 0
|
||||
break
|
||||
case 'priority':
|
||||
aValue = a.isSecurityUpdate ? 0 : 1 // Security updates first
|
||||
bValue = b.isSecurityUpdate ? 0 : 1
|
||||
break
|
||||
case "name":
|
||||
aValue = a.name?.toLowerCase() || "";
|
||||
bValue = b.name?.toLowerCase() || "";
|
||||
break;
|
||||
case "latestVersion":
|
||||
aValue = a.latestVersion?.toLowerCase() || "";
|
||||
bValue = b.latestVersion?.toLowerCase() || "";
|
||||
break;
|
||||
case "affectedHosts":
|
||||
aValue = a.affectedHostsCount || a.affectedHosts?.length || 0;
|
||||
bValue = b.affectedHostsCount || b.affectedHosts?.length || 0;
|
||||
break;
|
||||
case "priority":
|
||||
aValue = a.isSecurityUpdate ? 0 : 1; // Security updates first
|
||||
bValue = b.isSecurityUpdate ? 0 : 1;
|
||||
break;
|
||||
default:
|
||||
aValue = a.name?.toLowerCase() || ''
|
||||
bValue = b.name?.toLowerCase() || ''
|
||||
aValue = a.name?.toLowerCase() || "";
|
||||
bValue = b.name?.toLowerCase() || "";
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1
|
||||
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered
|
||||
}, [packages, searchTerm, categoryFilter, securityFilter, sortField, sortDirection])
|
||||
return filtered;
|
||||
}, [
|
||||
packages,
|
||||
searchTerm,
|
||||
categoryFilter,
|
||||
securityFilter,
|
||||
sortField,
|
||||
sortDirection,
|
||||
]);
|
||||
|
||||
// Get visible columns in order
|
||||
const visibleColumns = columnConfig
|
||||
.filter(col => col.visible)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.filter((col) => col.visible)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
// Sorting functions
|
||||
const handleSort = (field) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (field) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />
|
||||
return sortDirection === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
|
||||
}
|
||||
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />;
|
||||
return sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
);
|
||||
};
|
||||
|
||||
// Column management functions
|
||||
const toggleColumnVisibility = (columnId) => {
|
||||
const newConfig = columnConfig.map(col =>
|
||||
col.id === columnId ? { ...col, visible: !col.visible } : col
|
||||
)
|
||||
updateColumnConfig(newConfig)
|
||||
}
|
||||
const newConfig = columnConfig.map((col) =>
|
||||
col.id === columnId ? { ...col, visible: !col.visible } : col,
|
||||
);
|
||||
updateColumnConfig(newConfig);
|
||||
};
|
||||
|
||||
const reorderColumns = (fromIndex, toIndex) => {
|
||||
const newConfig = [...columnConfig]
|
||||
const [movedColumn] = newConfig.splice(fromIndex, 1)
|
||||
newConfig.splice(toIndex, 0, movedColumn)
|
||||
const newConfig = [...columnConfig];
|
||||
const [movedColumn] = newConfig.splice(fromIndex, 1);
|
||||
newConfig.splice(toIndex, 0, movedColumn);
|
||||
|
||||
// Update order values
|
||||
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
|
||||
updateColumnConfig(updatedConfig)
|
||||
}
|
||||
const updatedConfig = newConfig.map((col, index) => ({
|
||||
...col,
|
||||
order: index,
|
||||
}));
|
||||
updateColumnConfig(updatedConfig);
|
||||
};
|
||||
|
||||
const resetColumns = () => {
|
||||
const defaultConfig = [
|
||||
{ id: 'name', label: 'Package', visible: true, order: 0 },
|
||||
{ id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
|
||||
{ id: 'priority', label: 'Priority', visible: true, order: 2 },
|
||||
{ id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
|
||||
]
|
||||
updateColumnConfig(defaultConfig)
|
||||
}
|
||||
{ id: "name", label: "Package", visible: true, order: 0 },
|
||||
{ id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
|
||||
{ id: "priority", label: "Priority", visible: true, order: 2 },
|
||||
{ id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
|
||||
];
|
||||
updateColumnConfig(defaultConfig);
|
||||
};
|
||||
|
||||
// Helper function to render table cell content
|
||||
const renderCellContent = (column, pkg) => {
|
||||
switch (column.id) {
|
||||
case 'name':
|
||||
case "name":
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
@@ -241,9 +266,10 @@ const Packages = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'affectedHosts':
|
||||
const affectedHostsCount = pkg.affectedHostsCount || pkg.affectedHosts?.length || 0
|
||||
);
|
||||
case "affectedHosts": {
|
||||
const affectedHostsCount =
|
||||
pkg.affectedHostsCount || pkg.affectedHosts?.length || 0;
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleAffectedHostsClick(pkg)}
|
||||
@@ -251,11 +277,12 @@ const Packages = () => {
|
||||
title={`Click to view all ${affectedHostsCount} affected hosts`}
|
||||
>
|
||||
<div className="text-sm text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
|
||||
{affectedHostsCount} host{affectedHostsCount !== 1 ? 's' : ''}
|
||||
{affectedHostsCount} host{affectedHostsCount !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
case 'priority':
|
||||
);
|
||||
}
|
||||
case "priority":
|
||||
return pkg.isSecurityUpdate ? (
|
||||
<span className="badge-danger flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
@@ -263,61 +290,68 @@ const Packages = () => {
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning">Regular Update</span>
|
||||
)
|
||||
case 'latestVersion':
|
||||
);
|
||||
case "latestVersion":
|
||||
return (
|
||||
<div className="text-sm text-secondary-900 dark:text-white max-w-xs truncate" title={pkg.latestVersion || 'Unknown'}>
|
||||
{pkg.latestVersion || 'Unknown'}
|
||||
<div
|
||||
className="text-sm text-secondary-900 dark:text-white max-w-xs truncate"
|
||||
title={pkg.latestVersion || "Unknown"}
|
||||
>
|
||||
{pkg.latestVersion || "Unknown"}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
default:
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique categories
|
||||
const categories = [...new Set(packages?.map(pkg => pkg.category).filter(Boolean))] || []
|
||||
const categories =
|
||||
[...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || [];
|
||||
|
||||
// Calculate unique affected hosts
|
||||
const uniqueAffectedHosts = new Set()
|
||||
packages?.forEach(pkg => {
|
||||
const affectedHosts = pkg.affectedHosts || []
|
||||
affectedHosts.forEach(host => {
|
||||
uniqueAffectedHosts.add(host.hostId)
|
||||
})
|
||||
})
|
||||
const uniqueAffectedHostsCount = uniqueAffectedHosts.size
|
||||
const uniqueAffectedHosts = new Set();
|
||||
packages?.forEach((pkg) => {
|
||||
const affectedHosts = pkg.affectedHosts || [];
|
||||
affectedHosts.forEach((host) => {
|
||||
uniqueAffectedHosts.add(host.hostId);
|
||||
});
|
||||
});
|
||||
const uniqueAffectedHostsCount = uniqueAffectedHosts.size;
|
||||
|
||||
// Calculate total packages across all hosts (including up-to-date ones)
|
||||
const totalPackagesCount = hosts?.reduce((total, host) => {
|
||||
return total + (host.totalPackagesCount || 0)
|
||||
}, 0) || 0
|
||||
const totalPackagesCount =
|
||||
hosts?.reduce((total, host) => {
|
||||
return total + (host.totalPackagesCount || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
// Calculate outdated packages (packages that need updates)
|
||||
const outdatedPackagesCount = packages?.length || 0
|
||||
const outdatedPackagesCount = packages?.length || 0;
|
||||
|
||||
// Calculate security updates
|
||||
const securityUpdatesCount = packages?.filter(pkg => pkg.isSecurityUpdate).length || 0
|
||||
const securityUpdatesCount =
|
||||
packages?.filter((pkg) => pkg.isSecurityUpdate).length || 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">Error loading packages</h3>
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Error loading packages
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || 'Failed to load packages'}
|
||||
{error.message || "Failed to load packages"}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
@@ -329,7 +363,7 @@ const Packages = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -337,7 +371,9 @@ const Packages = () => {
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Packages</h1>
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
Packages
|
||||
</h1>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Manage package updates and security patches
|
||||
</p>
|
||||
@@ -349,8 +385,10 @@ const Packages = () => {
|
||||
className="btn-outline flex items-center gap-2"
|
||||
title="Refresh packages data"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
{isFetching ? 'Refreshing...' : 'Refresh'}
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isFetching ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,8 +399,12 @@ const Packages = () => {
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Total Packages</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{totalPackagesCount}</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Total Packages
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{totalPackagesCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,7 +413,9 @@ const Packages = () => {
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-warning-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Total Outdated Packages</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Total Outdated Packages
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{outdatedPackagesCount}
|
||||
</p>
|
||||
@@ -383,7 +427,9 @@ const Packages = () => {
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-warning-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Hosts Pending Updates</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Hosts Pending Updates
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{uniqueAffectedHostsCount}
|
||||
</p>
|
||||
@@ -395,8 +441,12 @@ const Packages = () => {
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-5 w-5 text-danger-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Security Updates Across All Hosts</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{securityUpdatesCount}</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Security Updates Across All Hosts
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{securityUpdatesCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,8 +484,10 @@ const Packages = () => {
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{categories.map(category => (
|
||||
<option key={category} value={category}>{category}</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -461,8 +513,10 @@ const Packages = () => {
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All Hosts</option>
|
||||
{hosts?.map(host => (
|
||||
<option key={host.id} value={host.id}>{host.friendly_name}</option>
|
||||
{hosts?.map((host) => (
|
||||
<option key={host.id} value={host.id}>
|
||||
{host.friendly_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -485,7 +539,9 @@ const Packages = () => {
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
{packages?.length === 0 ? 'No packages need updates' : 'No packages match your filters'}
|
||||
{packages?.length === 0
|
||||
? "No packages need updates"
|
||||
: "No packages match your filters"}
|
||||
</p>
|
||||
{packages?.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
@@ -499,7 +555,10 @@ const Packages = () => {
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
|
||||
<tr>
|
||||
{visibleColumns.map((column) => (
|
||||
<th key={column.id} className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<th
|
||||
key={column.id}
|
||||
className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSort(column.id)}
|
||||
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
|
||||
@@ -513,9 +572,15 @@ const Packages = () => {
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredAndSortedPackages.map((pkg) => (
|
||||
<tr key={pkg.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
|
||||
<tr
|
||||
key={pkg.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<td
|
||||
key={column.id}
|
||||
className="px-4 py-2 whitespace-nowrap text-center"
|
||||
>
|
||||
{renderCellContent(column, pkg)}
|
||||
</td>
|
||||
))}
|
||||
@@ -540,37 +605,48 @@ const Packages = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Column Settings Modal Component
|
||||
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
|
||||
const [draggedIndex, setDraggedIndex] = useState(null)
|
||||
const ColumnSettingsModal = ({
|
||||
columnConfig,
|
||||
onClose,
|
||||
onToggleVisibility,
|
||||
onReorder,
|
||||
onReset,
|
||||
}) => {
|
||||
const [draggedIndex, setDraggedIndex] = useState(null);
|
||||
|
||||
const handleDragStart = (e, index) => {
|
||||
setDraggedIndex(index)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
setDraggedIndex(index);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
};
|
||||
|
||||
const handleDrop = (e, dropIndex) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null && draggedIndex !== dropIndex) {
|
||||
onReorder(draggedIndex, dropIndex)
|
||||
}
|
||||
setDraggedIndex(null)
|
||||
onReorder(draggedIndex, dropIndex);
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Customize Columns</h3>
|
||||
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Customize Columns
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -584,7 +660,9 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
className={`flex items-center justify-between p-3 border rounded-lg cursor-move ${
|
||||
draggedIndex === index ? 'opacity-50' : 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
draggedIndex === index
|
||||
? "opacity-50"
|
||||
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
} border-secondary-200 dark:border-secondary-600`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -597,11 +675,15 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
|
||||
onClick={() => onToggleVisibility(column.id)}
|
||||
className={`p-1 rounded ${
|
||||
column.visible
|
||||
? 'text-primary-600 hover:text-primary-700'
|
||||
: 'text-secondary-400 hover:text-secondary-600'
|
||||
? "text-primary-600 hover:text-primary-700"
|
||||
: "text-secondary-400 hover:text-secondary-600"
|
||||
}`}
|
||||
>
|
||||
{column.visible ? <EyeIcon className="h-4 w-4" /> : <EyeOffIcon className="h-4 w-4" />}
|
||||
{column.visible ? (
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -623,7 +705,7 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Packages
|
||||
export default Packages;
|
||||
|
||||
@@ -1,80 +1,89 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Shield,
|
||||
Settings,
|
||||
Users,
|
||||
Server,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Download,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Package,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
AlertTriangle,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
import { permissionsAPI } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { permissionsAPI } from "../utils/api";
|
||||
|
||||
const Permissions = () => {
|
||||
const [editingRole, setEditingRole] = useState(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { refreshPermissions } = useAuth()
|
||||
const [editingRole, setEditingRole] = useState(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { refreshPermissions } = useAuth();
|
||||
|
||||
// Fetch all role permissions
|
||||
const { data: roles, isLoading, error } = useQuery({
|
||||
queryKey: ['rolePermissions'],
|
||||
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
|
||||
})
|
||||
const {
|
||||
data: roles,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["rolePermissions"],
|
||||
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Update role permissions mutation
|
||||
const updateRoleMutation = useMutation({
|
||||
mutationFn: ({ role, permissions }) => permissionsAPI.updateRole(role, permissions),
|
||||
mutationFn: ({ role, permissions }) =>
|
||||
permissionsAPI.updateRole(role, permissions),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['rolePermissions'])
|
||||
setEditingRole(null)
|
||||
queryClient.invalidateQueries(["rolePermissions"]);
|
||||
setEditingRole(null);
|
||||
// Refresh user permissions to apply changes immediately
|
||||
refreshPermissions()
|
||||
}
|
||||
})
|
||||
refreshPermissions();
|
||||
},
|
||||
});
|
||||
|
||||
// Delete role mutation
|
||||
const deleteRoleMutation = useMutation({
|
||||
mutationFn: (role) => permissionsAPI.deleteRole(role),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['rolePermissions'])
|
||||
}
|
||||
})
|
||||
queryClient.invalidateQueries(["rolePermissions"]);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSavePermissions = async (role, permissions) => {
|
||||
try {
|
||||
await updateRoleMutation.mutateAsync({ role, permissions })
|
||||
await updateRoleMutation.mutateAsync({ role, permissions });
|
||||
} catch (error) {
|
||||
console.error('Failed to update permissions:', error)
|
||||
}
|
||||
console.error("Failed to update permissions:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRole = async (role) => {
|
||||
if (window.confirm(`Are you sure you want to delete the "${role}" role? This action cannot be undone.`)) {
|
||||
if (
|
||||
window.confirm(
|
||||
`Are you sure you want to delete the "${role}" role? This action cannot be undone.`,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await deleteRoleMutation.mutateAsync(role)
|
||||
await deleteRoleMutation.mutateAsync(role);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete role:', error)
|
||||
}
|
||||
console.error("Failed to delete role:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -83,12 +92,14 @@ const Permissions = () => {
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">Error loading permissions</h3>
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Error loading permissions
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -115,7 +126,9 @@ const Permissions = () => {
|
||||
|
||||
{/* Roles List */}
|
||||
<div className="space-y-4">
|
||||
{roles && Array.isArray(roles) && roles.map((role) => (
|
||||
{roles &&
|
||||
Array.isArray(roles) &&
|
||||
roles.map((role) => (
|
||||
<RolePermissionsCard
|
||||
key={role.id}
|
||||
role={role}
|
||||
@@ -133,48 +146,105 @@ const Permissions = () => {
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries(['rolePermissions'])
|
||||
setShowAddModal(false)
|
||||
queryClient.invalidateQueries(["rolePermissions"]);
|
||||
setShowAddModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Role Permissions Card Component
|
||||
const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => {
|
||||
const [permissions, setPermissions] = useState(role)
|
||||
const RolePermissionsCard = ({
|
||||
role,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
onDelete,
|
||||
}) => {
|
||||
const [permissions, setPermissions] = useState(role);
|
||||
|
||||
// Sync permissions state with role prop when it changes
|
||||
useEffect(() => {
|
||||
setPermissions(role)
|
||||
}, [role])
|
||||
setPermissions(role);
|
||||
}, [role]);
|
||||
|
||||
const permissionFields = [
|
||||
{ key: 'can_view_dashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' },
|
||||
{ key: 'can_view_hosts', label: 'View Hosts', icon: Server, description: 'See host information and status' },
|
||||
{ key: 'can_manage_hosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' },
|
||||
{ key: 'can_view_packages', label: 'View Packages', icon: Package, description: 'See package information' },
|
||||
{ key: 'can_manage_packages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' },
|
||||
{ key: 'can_view_users', label: 'View Users', icon: Users, description: 'See user list and details' },
|
||||
{ key: 'can_manage_users', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' },
|
||||
{ key: 'can_view_reports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' },
|
||||
{ key: 'can_export_data', label: 'Export Data', icon: Download, description: 'Download data and reports' },
|
||||
{ key: 'can_manage_settings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' }
|
||||
]
|
||||
{
|
||||
key: "can_view_dashboard",
|
||||
label: "View Dashboard",
|
||||
icon: BarChart3,
|
||||
description: "Access to the main dashboard",
|
||||
},
|
||||
{
|
||||
key: "can_view_hosts",
|
||||
label: "View Hosts",
|
||||
icon: Server,
|
||||
description: "See host information and status",
|
||||
},
|
||||
{
|
||||
key: "can_manage_hosts",
|
||||
label: "Manage Hosts",
|
||||
icon: Edit,
|
||||
description: "Add, edit, and delete hosts",
|
||||
},
|
||||
{
|
||||
key: "can_view_packages",
|
||||
label: "View Packages",
|
||||
icon: Package,
|
||||
description: "See package information",
|
||||
},
|
||||
{
|
||||
key: "can_manage_packages",
|
||||
label: "Manage Packages",
|
||||
icon: Settings,
|
||||
description: "Edit package details",
|
||||
},
|
||||
{
|
||||
key: "can_view_users",
|
||||
label: "View Users",
|
||||
icon: Users,
|
||||
description: "See user list and details",
|
||||
},
|
||||
{
|
||||
key: "can_manage_users",
|
||||
label: "Manage Users",
|
||||
icon: Shield,
|
||||
description: "Add, edit, and delete users",
|
||||
},
|
||||
{
|
||||
key: "can_view_reports",
|
||||
label: "View Reports",
|
||||
icon: BarChart3,
|
||||
description: "Access to reports and analytics",
|
||||
},
|
||||
{
|
||||
key: "can_export_data",
|
||||
label: "Export Data",
|
||||
icon: Download,
|
||||
description: "Download data and reports",
|
||||
},
|
||||
{
|
||||
key: "can_manage_settings",
|
||||
label: "Manage Settings",
|
||||
icon: Settings,
|
||||
description: "System configuration access",
|
||||
},
|
||||
];
|
||||
|
||||
const handlePermissionChange = (key, value) => {
|
||||
setPermissions(prev => ({
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}))
|
||||
}
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(role.role, permissions)
|
||||
}
|
||||
onSave(role.role, permissions);
|
||||
};
|
||||
|
||||
const isBuiltInRole = role.role === 'admin' || role.role === 'user'
|
||||
const isBuiltInRole = role.role === "admin" || role.role === "user";
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
|
||||
@@ -182,7 +252,9 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-5 w-5 text-primary-600 mr-3" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">{role.role}</h3>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">
|
||||
{role.role}
|
||||
</h3>
|
||||
{isBuiltInRole && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
|
||||
Built-in Role
|
||||
@@ -235,8 +307,8 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
||||
<div className="px-6 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{permissionFields.map((field) => {
|
||||
const Icon = field.icon
|
||||
const isChecked = permissions[field.key]
|
||||
const Icon = field.icon;
|
||||
const isChecked = permissions[field.key];
|
||||
|
||||
return (
|
||||
<div key={field.key} className="flex items-start">
|
||||
@@ -244,8 +316,13 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => handlePermissionChange(field.key, e.target.checked)}
|
||||
disabled={!isEditing || (isBuiltInRole && field.key === 'can_manage_users')}
|
||||
onChange={(e) =>
|
||||
handlePermissionChange(field.key, e.target.checked)
|
||||
}
|
||||
disabled={
|
||||
!isEditing ||
|
||||
(isBuiltInRole && field.key === "can_manage_users")
|
||||
}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
@@ -261,18 +338,18 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Add Role Modal Component
|
||||
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
role: '',
|
||||
role: "",
|
||||
can_view_dashboard: true,
|
||||
can_view_hosts: true,
|
||||
can_manage_hosts: false,
|
||||
@@ -282,40 +359,42 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
can_manage_users: false,
|
||||
can_view_reports: true,
|
||||
can_export_data: false,
|
||||
can_manage_settings: false
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
can_manage_settings: false,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await permissionsAPI.updateRole(formData.role, formData)
|
||||
onSuccess()
|
||||
await permissionsAPI.updateRole(formData.role, formData);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to create role')
|
||||
setError(err.response?.data?.error || "Failed to create role");
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
})
|
||||
}
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Add New Role</h3>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Add New Role
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
@@ -331,22 +410,26 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
placeholder="e.g., host_manager, readonly"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">Use lowercase with underscores (e.g., host_manager)</p>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Use lowercase with underscores (e.g., host_manager)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">Permissions</h4>
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
Permissions
|
||||
</h4>
|
||||
{[
|
||||
{ key: 'can_view_dashboard', label: 'View Dashboard' },
|
||||
{ key: 'can_view_hosts', label: 'View Hosts' },
|
||||
{ key: 'can_manage_hosts', label: 'Manage Hosts' },
|
||||
{ key: 'can_view_packages', label: 'View Packages' },
|
||||
{ key: 'can_manage_packages', label: 'Manage Packages' },
|
||||
{ key: 'can_view_users', label: 'View Users' },
|
||||
{ key: 'can_manage_users', label: 'Manage Users' },
|
||||
{ key: 'can_view_reports', label: 'View Reports' },
|
||||
{ key: 'can_export_data', label: 'Export Data' },
|
||||
{ key: 'can_manage_settings', label: 'Manage Settings' }
|
||||
{ key: "can_view_dashboard", label: "View Dashboard" },
|
||||
{ key: "can_view_hosts", label: "View Hosts" },
|
||||
{ key: "can_manage_hosts", label: "Manage Hosts" },
|
||||
{ key: "can_view_packages", label: "View Packages" },
|
||||
{ key: "can_manage_packages", label: "Manage Packages" },
|
||||
{ key: "can_view_users", label: "View Users" },
|
||||
{ key: "can_manage_users", label: "Manage Users" },
|
||||
{ key: "can_view_reports", label: "View Reports" },
|
||||
{ key: "can_export_data", label: "Export Data" },
|
||||
{ key: "can_manage_settings", label: "Manage Settings" },
|
||||
].map((permission) => (
|
||||
<div key={permission.key} className="flex items-center">
|
||||
<input
|
||||
@@ -365,7 +448,9 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -382,13 +467,13 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Role'}
|
||||
{isLoading ? "Creating..." : "Create Role"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Permissions
|
||||
export default Permissions;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,55 +1,55 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
Columns,
|
||||
Database,
|
||||
Eye,
|
||||
Globe,
|
||||
GripVertical,
|
||||
Lock,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
Globe,
|
||||
Lock,
|
||||
Unlock,
|
||||
Database,
|
||||
Eye,
|
||||
Search,
|
||||
Columns,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Users,
|
||||
X,
|
||||
GripVertical,
|
||||
Check,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { repositoryAPI } from '../utils/api';
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { repositoryAPI } from "../utils/api";
|
||||
|
||||
const Repositories = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterType, setFilterType] = useState('all'); // all, secure, insecure
|
||||
const [filterStatus, setFilterStatus] = useState('all'); // all, active, inactive
|
||||
const [sortField, setSortField] = useState('name');
|
||||
const [sortDirection, setSortDirection] = useState('asc');
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterType, setFilterType] = useState("all"); // all, secure, insecure
|
||||
const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive
|
||||
const [sortField, setSortField] = useState("name");
|
||||
const [sortDirection, setSortDirection] = useState("asc");
|
||||
const [showColumnSettings, setShowColumnSettings] = useState(false);
|
||||
|
||||
// Column configuration
|
||||
const [columnConfig, setColumnConfig] = useState(() => {
|
||||
const defaultConfig = [
|
||||
{ id: 'name', label: 'Repository', visible: true, order: 0 },
|
||||
{ id: 'url', label: 'URL', visible: true, order: 1 },
|
||||
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 },
|
||||
{ id: 'security', label: 'Security', visible: true, order: 3 },
|
||||
{ id: 'status', label: 'Status', visible: true, order: 4 },
|
||||
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
|
||||
{ id: 'actions', label: 'Actions', visible: true, order: 6 }
|
||||
{ id: "name", label: "Repository", visible: true, order: 0 },
|
||||
{ id: "url", label: "URL", visible: true, order: 1 },
|
||||
{ id: "distribution", label: "Distribution", visible: true, order: 2 },
|
||||
{ id: "security", label: "Security", visible: true, order: 3 },
|
||||
{ id: "status", label: "Status", visible: true, order: 4 },
|
||||
{ id: "hostCount", label: "Hosts", visible: true, order: 5 },
|
||||
{ id: "actions", label: "Actions", visible: true, order: 6 },
|
||||
];
|
||||
|
||||
const saved = localStorage.getItem('repositories-column-config');
|
||||
const saved = localStorage.getItem("repositories-column-config");
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved column config:', e);
|
||||
console.error("Failed to parse saved column config:", e);
|
||||
}
|
||||
}
|
||||
return defaultConfig;
|
||||
@@ -57,92 +57,114 @@ const Repositories = () => {
|
||||
|
||||
const updateColumnConfig = (newConfig) => {
|
||||
setColumnConfig(newConfig);
|
||||
localStorage.setItem('repositories-column-config', JSON.stringify(newConfig));
|
||||
localStorage.setItem(
|
||||
"repositories-column-config",
|
||||
JSON.stringify(newConfig),
|
||||
);
|
||||
};
|
||||
|
||||
// Fetch repositories
|
||||
const { data: repositories = [], isLoading, error, refetch, isFetching } = useQuery({
|
||||
queryKey: ['repositories'],
|
||||
queryFn: () => repositoryAPI.list().then(res => res.data)
|
||||
const {
|
||||
data: repositories = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["repositories"],
|
||||
queryFn: () => repositoryAPI.list().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Fetch repository statistics
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['repository-stats'],
|
||||
queryFn: () => repositoryAPI.getStats().then(res => res.data)
|
||||
queryKey: ["repository-stats"],
|
||||
queryFn: () => repositoryAPI.getStats().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Get visible columns in order
|
||||
const visibleColumns = columnConfig
|
||||
.filter(col => col.visible)
|
||||
.filter((col) => col.visible)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
// Sorting functions
|
||||
const handleSort = (field) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (field) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />
|
||||
return sortDirection === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
|
||||
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />;
|
||||
return sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
);
|
||||
};
|
||||
|
||||
// Column management functions
|
||||
const toggleColumnVisibility = (columnId) => {
|
||||
const newConfig = columnConfig.map(col =>
|
||||
col.id === columnId ? { ...col, visible: !col.visible } : col
|
||||
)
|
||||
updateColumnConfig(newConfig)
|
||||
const newConfig = columnConfig.map((col) =>
|
||||
col.id === columnId ? { ...col, visible: !col.visible } : col,
|
||||
);
|
||||
updateColumnConfig(newConfig);
|
||||
};
|
||||
|
||||
const reorderColumns = (fromIndex, toIndex) => {
|
||||
const newConfig = [...columnConfig]
|
||||
const [movedColumn] = newConfig.splice(fromIndex, 1)
|
||||
newConfig.splice(toIndex, 0, movedColumn)
|
||||
const newConfig = [...columnConfig];
|
||||
const [movedColumn] = newConfig.splice(fromIndex, 1);
|
||||
newConfig.splice(toIndex, 0, movedColumn);
|
||||
|
||||
// Update order values
|
||||
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
|
||||
updateColumnConfig(updatedConfig)
|
||||
const updatedConfig = newConfig.map((col, index) => ({
|
||||
...col,
|
||||
order: index,
|
||||
}));
|
||||
updateColumnConfig(updatedConfig);
|
||||
};
|
||||
|
||||
const resetColumns = () => {
|
||||
const defaultConfig = [
|
||||
{ id: 'name', label: 'Repository', visible: true, order: 0 },
|
||||
{ id: 'url', label: 'URL', visible: true, order: 1 },
|
||||
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 },
|
||||
{ id: 'security', label: 'Security', visible: true, order: 3 },
|
||||
{ id: 'status', label: 'Status', visible: true, order: 4 },
|
||||
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
|
||||
{ id: 'actions', label: 'Actions', visible: true, order: 6 }
|
||||
]
|
||||
updateColumnConfig(defaultConfig)
|
||||
{ id: "name", label: "Repository", visible: true, order: 0 },
|
||||
{ id: "url", label: "URL", visible: true, order: 1 },
|
||||
{ id: "distribution", label: "Distribution", visible: true, order: 2 },
|
||||
{ id: "security", label: "Security", visible: true, order: 3 },
|
||||
{ id: "status", label: "Status", visible: true, order: 4 },
|
||||
{ id: "hostCount", label: "Hosts", visible: true, order: 5 },
|
||||
{ id: "actions", label: "Actions", visible: true, order: 6 },
|
||||
];
|
||||
updateColumnConfig(defaultConfig);
|
||||
};
|
||||
|
||||
// Filter and sort repositories
|
||||
const filteredAndSortedRepositories = useMemo(() => {
|
||||
if (!repositories) return []
|
||||
if (!repositories) return [];
|
||||
|
||||
// Filter repositories
|
||||
const filtered = repositories.filter(repo => {
|
||||
const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
const filtered = repositories.filter((repo) => {
|
||||
const matchesSearch =
|
||||
repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
// Check security based on URL if isSecure property doesn't exist
|
||||
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
|
||||
const isSecure =
|
||||
repo.isSecure !== undefined
|
||||
? repo.isSecure
|
||||
: repo.url.startsWith("https://");
|
||||
|
||||
const matchesType = filterType === 'all' ||
|
||||
(filterType === 'secure' && isSecure) ||
|
||||
(filterType === 'insecure' && !isSecure);
|
||||
const matchesType =
|
||||
filterType === "all" ||
|
||||
(filterType === "secure" && isSecure) ||
|
||||
(filterType === "insecure" && !isSecure);
|
||||
|
||||
const matchesStatus = filterStatus === 'all' ||
|
||||
(filterStatus === 'active' && repo.is_active === true) ||
|
||||
(filterStatus === 'inactive' && repo.is_active === false);
|
||||
const matchesStatus =
|
||||
filterStatus === "all" ||
|
||||
(filterStatus === "active" && repo.is_active === true) ||
|
||||
(filterStatus === "inactive" && repo.is_active === false);
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
});
|
||||
@@ -153,26 +175,33 @@ const Repositories = () => {
|
||||
let bValue = b[sortField];
|
||||
|
||||
// Handle special cases
|
||||
if (sortField === 'security') {
|
||||
aValue = a.isSecure ? 'Secure' : 'Insecure';
|
||||
bValue = b.isSecure ? 'Secure' : 'Insecure';
|
||||
} else if (sortField === 'status') {
|
||||
aValue = a.is_active ? 'Active' : 'Inactive';
|
||||
bValue = b.is_active ? 'Active' : 'Inactive';
|
||||
if (sortField === "security") {
|
||||
aValue = a.isSecure ? "Secure" : "Insecure";
|
||||
bValue = b.isSecure ? "Secure" : "Insecure";
|
||||
} else if (sortField === "status") {
|
||||
aValue = a.is_active ? "Active" : "Inactive";
|
||||
bValue = b.is_active ? "Active" : "Inactive";
|
||||
}
|
||||
|
||||
if (typeof aValue === 'string') {
|
||||
if (typeof aValue === "string") {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
|
||||
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}, [repositories, searchTerm, filterType, filterStatus, sortField, sortDirection]);
|
||||
}, [
|
||||
repositories,
|
||||
searchTerm,
|
||||
filterType,
|
||||
filterStatus,
|
||||
sortField,
|
||||
sortDirection,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -200,7 +229,9 @@ const Repositories = () => {
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Repositories</h1>
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
Repositories
|
||||
</h1>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Manage and monitor your package repositories
|
||||
</p>
|
||||
@@ -212,8 +243,10 @@ const Repositories = () => {
|
||||
className="btn-outline flex items-center gap-2"
|
||||
title="Refresh repositories data"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
{isFetching ? 'Refreshing...' : 'Refresh'}
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isFetching ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,8 +257,12 @@ const Repositories = () => {
|
||||
<div className="flex items-center">
|
||||
<Database className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Total Repositories</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.totalRepositories || 0}</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Total Repositories
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{stats?.totalRepositories || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,8 +271,12 @@ const Repositories = () => {
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-success-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Active Repositories</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.activeRepositories || 0}</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Active Repositories
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{stats?.activeRepositories || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,8 +285,12 @@ const Repositories = () => {
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-5 w-5 text-warning-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Secure (HTTPS)</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.secureRepositories || 0}</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Secure (HTTPS)
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{stats?.secureRepositories || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,8 +299,12 @@ const Repositories = () => {
|
||||
<div className="flex items-center">
|
||||
<ShieldCheck className="h-5 w-5 text-danger-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Security Score</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.securityPercentage || 0}%</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">
|
||||
Security Score
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{stats?.securityPercentage || 0}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -329,7 +378,9 @@ const Repositories = () => {
|
||||
<div className="text-center py-8">
|
||||
<Database className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
{repositories?.length === 0 ? 'No repositories found' : 'No repositories match your filters'}
|
||||
{repositories?.length === 0
|
||||
? "No repositories found"
|
||||
: "No repositories match your filters"}
|
||||
</p>
|
||||
{repositories?.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
@@ -343,7 +394,10 @@ const Repositories = () => {
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
|
||||
<tr>
|
||||
{visibleColumns.map((column) => (
|
||||
<th key={column.id} className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<th
|
||||
key={column.id}
|
||||
className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSort(column.id)}
|
||||
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
|
||||
@@ -357,9 +411,15 @@ const Repositories = () => {
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredAndSortedRepositories.map((repo) => (
|
||||
<tr key={repo.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
|
||||
<tr
|
||||
key={repo.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<td
|
||||
key={column.id}
|
||||
className="px-4 py-2 whitespace-nowrap text-center"
|
||||
>
|
||||
{renderCellContent(column, repo)}
|
||||
</td>
|
||||
))}
|
||||
@@ -389,7 +449,7 @@ const Repositories = () => {
|
||||
// Render cell content based on column type
|
||||
function renderCellContent(column, repo) {
|
||||
switch (column.id) {
|
||||
case 'name':
|
||||
case "name":
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Database className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
@@ -399,21 +459,27 @@ const Repositories = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'url':
|
||||
);
|
||||
case "url":
|
||||
return (
|
||||
<div className="text-sm text-secondary-900 dark:text-white max-w-xs truncate" title={repo.url}>
|
||||
<div
|
||||
className="text-sm text-secondary-900 dark:text-white max-w-xs truncate"
|
||||
title={repo.url}
|
||||
>
|
||||
{repo.url}
|
||||
</div>
|
||||
)
|
||||
case 'distribution':
|
||||
);
|
||||
case "distribution":
|
||||
return (
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{repo.distribution}
|
||||
</div>
|
||||
)
|
||||
case 'security':
|
||||
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
|
||||
);
|
||||
case "security": {
|
||||
const isSecure =
|
||||
repo.isSecure !== undefined
|
||||
? repo.isSecure
|
||||
: repo.url.startsWith("https://");
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
{isSecure ? (
|
||||
@@ -428,25 +494,28 @@ const Repositories = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 'status':
|
||||
);
|
||||
}
|
||||
case "status":
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
repo.is_active
|
||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||
}`}>
|
||||
{repo.is_active ? 'Active' : 'Inactive'}
|
||||
? "bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300"
|
||||
: "bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{repo.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
)
|
||||
case 'hostCount':
|
||||
);
|
||||
case "hostCount":
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{repo.host_count}</span>
|
||||
</div>
|
||||
)
|
||||
case 'actions':
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<Link
|
||||
to={`/repositories/${repo.id}`}
|
||||
@@ -455,41 +524,52 @@ const Repositories = () => {
|
||||
View
|
||||
<Eye className="h-3 w-3" />
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
default:
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Column Settings Modal Component
|
||||
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
|
||||
const [draggedIndex, setDraggedIndex] = useState(null)
|
||||
const ColumnSettingsModal = ({
|
||||
columnConfig,
|
||||
onClose,
|
||||
onToggleVisibility,
|
||||
onReorder,
|
||||
onReset,
|
||||
}) => {
|
||||
const [draggedIndex, setDraggedIndex] = useState(null);
|
||||
|
||||
const handleDragStart = (e, index) => {
|
||||
setDraggedIndex(index)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
setDraggedIndex(index);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
};
|
||||
|
||||
const handleDrop = (e, dropIndex) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null && draggedIndex !== dropIndex) {
|
||||
onReorder(draggedIndex, dropIndex)
|
||||
}
|
||||
setDraggedIndex(null)
|
||||
onReorder(draggedIndex, dropIndex);
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Column Settings</h3>
|
||||
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Column Settings
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -514,8 +594,8 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
|
||||
onClick={() => onToggleVisibility(column.id)}
|
||||
className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
|
||||
column.visible
|
||||
? 'bg-primary-600 border-primary-600'
|
||||
: 'bg-white dark:bg-secondary-800 border-secondary-300 dark:border-secondary-600'
|
||||
? "bg-primary-600 border-primary-600"
|
||||
: "bg-white dark:bg-secondary-800 border-secondary-300 dark:border-secondary-600"
|
||||
}`}
|
||||
>
|
||||
{column.visible && <Check className="h-3 w-3 text-white" />}
|
||||
@@ -540,7 +620,7 @@ const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Repositories;
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Database,
|
||||
Globe,
|
||||
Lock,
|
||||
Server,
|
||||
Shield,
|
||||
ShieldOff,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
Globe,
|
||||
Lock,
|
||||
Unlock,
|
||||
Database,
|
||||
Calendar,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { repositoryAPI } from '../utils/api';
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { repositoryAPI } from "../utils/api";
|
||||
|
||||
const RepositoryDetail = () => {
|
||||
const { repositoryId } = useParams();
|
||||
@@ -24,29 +24,32 @@ const RepositoryDetail = () => {
|
||||
const [formData, setFormData] = useState({});
|
||||
|
||||
// Fetch repository details
|
||||
const { data: repository, isLoading, error } = useQuery({
|
||||
queryKey: ['repository', repositoryId],
|
||||
queryFn: () => repositoryAPI.getById(repositoryId).then(res => res.data),
|
||||
enabled: !!repositoryId
|
||||
const {
|
||||
data: repository,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["repository", repositoryId],
|
||||
queryFn: () => repositoryAPI.getById(repositoryId).then((res) => res.data),
|
||||
enabled: !!repositoryId,
|
||||
});
|
||||
|
||||
// Update repository mutation
|
||||
const updateRepositoryMutation = useMutation({
|
||||
mutationFn: (data) => repositoryAPI.update(repositoryId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['repository', repositoryId]);
|
||||
queryClient.invalidateQueries(['repositories']);
|
||||
queryClient.invalidateQueries(["repository", repositoryId]);
|
||||
queryClient.invalidateQueries(["repositories"]);
|
||||
setEditMode(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const handleEdit = () => {
|
||||
setFormData({
|
||||
name: repository.name,
|
||||
description: repository.description || '',
|
||||
description: repository.description || "",
|
||||
is_active: repository.is_active,
|
||||
priority: repository.priority || ''
|
||||
priority: repository.priority || "",
|
||||
});
|
||||
setEditMode(true);
|
||||
};
|
||||
@@ -60,7 +63,6 @@ const RepositoryDetail = () => {
|
||||
setFormData({});
|
||||
};
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -107,7 +109,9 @@ const RepositoryDetail = () => {
|
||||
</div>
|
||||
<div className="text-center py-12">
|
||||
<Database className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">Repository not found</h3>
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
|
||||
Repository not found
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
|
||||
The repository you're looking for doesn't exist.
|
||||
</p>
|
||||
@@ -138,12 +142,14 @@ const RepositoryDetail = () => {
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{repository.name}
|
||||
</h1>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
repository.is_active
|
||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||
}`}>
|
||||
{repository.is_active ? 'Active' : 'Inactive'}
|
||||
? "bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300"
|
||||
: "bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{repository.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
|
||||
@@ -166,14 +172,13 @@ const RepositoryDetail = () => {
|
||||
className="btn-primary"
|
||||
disabled={updateRepositoryMutation.isPending}
|
||||
>
|
||||
{updateRepositoryMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
{updateRepositoryMutation.isPending
|
||||
? "Saving..."
|
||||
: "Save Changes"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="btn-primary"
|
||||
>
|
||||
<button onClick={handleEdit} className="btn-primary">
|
||||
Edit Repository
|
||||
</button>
|
||||
)}
|
||||
@@ -197,7 +202,9 @@ const RepositoryDetail = () => {
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
@@ -208,7 +215,9 @@ const RepositoryDetail = () => {
|
||||
<input
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, priority: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
||||
placeholder="Optional priority"
|
||||
/>
|
||||
@@ -219,7 +228,9 @@ const RepositoryDetail = () => {
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
rows="3"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
||||
placeholder="Optional description"
|
||||
@@ -230,10 +241,15 @@ const RepositoryDetail = () => {
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, is_active: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||
/>
|
||||
<label htmlFor="is_active" className="ml-2 block text-sm text-secondary-900 dark:text-white">
|
||||
<label
|
||||
htmlFor="is_active"
|
||||
className="ml-2 block text-sm text-secondary-900 dark:text-white"
|
||||
>
|
||||
Repository is active
|
||||
</label>
|
||||
</div>
|
||||
@@ -242,28 +258,46 @@ const RepositoryDetail = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">URL</label>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
URL
|
||||
</label>
|
||||
<div className="flex items-center mt-1">
|
||||
<Globe className="h-4 w-4 text-secondary-400 mr-2" />
|
||||
<span className="text-secondary-900 dark:text-white">{repository.url}</span>
|
||||
<span className="text-secondary-900 dark:text-white">
|
||||
{repository.url}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Distribution</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">{repository.distribution}</p>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Distribution
|
||||
</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">
|
||||
{repository.distribution}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Components</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">{repository.components}</p>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Components
|
||||
</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">
|
||||
{repository.components}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Repository Type</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">{repository.repoType}</p>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Repository Type
|
||||
</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">
|
||||
{repository.repoType}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Security</label>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Security
|
||||
</label>
|
||||
<div className="flex items-center mt-1">
|
||||
{repository.isSecure ? (
|
||||
<>
|
||||
@@ -280,18 +314,28 @@ const RepositoryDetail = () => {
|
||||
</div>
|
||||
{repository.priority && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Priority</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">{repository.priority}</p>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Priority
|
||||
</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">
|
||||
{repository.priority}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{repository.description && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Description</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">{repository.description}</p>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Description
|
||||
</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">
|
||||
{repository.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Created</label>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
Created
|
||||
</label>
|
||||
<div className="flex items-center mt-1">
|
||||
<Calendar className="h-4 w-4 text-secondary-400 mr-2" />
|
||||
<span className="text-secondary-900 dark:text-white">
|
||||
@@ -310,13 +354,17 @@ const RepositoryDetail = () => {
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Hosts Using This Repository ({repository.host_repositories?.length || 0})
|
||||
Hosts Using This Repository (
|
||||
{repository.host_repositories?.length || 0})
|
||||
</h2>
|
||||
</div>
|
||||
{!repository.host_repositories || repository.host_repositories.length === 0 ? (
|
||||
{!repository.host_repositories ||
|
||||
repository.host_repositories.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<Server className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No hosts using this repository</h3>
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
|
||||
No hosts using this repository
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
|
||||
This repository hasn't been reported by any hosts yet.
|
||||
</p>
|
||||
@@ -324,16 +372,21 @@ const RepositoryDetail = () => {
|
||||
) : (
|
||||
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
{repository.host_repositories.map((hostRepo) => (
|
||||
<div key={hostRepo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
|
||||
<div
|
||||
key={hostRepo.id}
|
||||
className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
hostRepo.hosts.status === 'active'
|
||||
? 'bg-green-500'
|
||||
: hostRepo.hosts.status === 'pending'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`} />
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
hostRepo.hosts.status === "active"
|
||||
? "bg-green-500"
|
||||
: hostRepo.hosts.status === "pending"
|
||||
? "bg-yellow-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<Link
|
||||
to={`/hosts/${hostRepo.hosts.id}`}
|
||||
@@ -343,14 +396,24 @@ const RepositoryDetail = () => {
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
<span>IP: {hostRepo.hosts.ip}</span>
|
||||
<span>OS: {hostRepo.hosts.os_type} {hostRepo.hosts.os_version}</span>
|
||||
<span>Last Update: {new Date(hostRepo.hosts.last_update).toLocaleDateString()}</span>
|
||||
<span>
|
||||
OS: {hostRepo.hosts.os_type}{" "}
|
||||
{hostRepo.hosts.os_version}
|
||||
</span>
|
||||
<span>
|
||||
Last Update:{" "}
|
||||
{new Date(
|
||||
hostRepo.hosts.last_update,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">Last Checked</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Last Checked
|
||||
</div>
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{new Date(hostRepo.last_checked).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,83 +1,103 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle, Key } from 'lucide-react'
|
||||
import { adminUsersAPI, permissionsAPI } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Edit,
|
||||
Key,
|
||||
Mail,
|
||||
Plus,
|
||||
Shield,
|
||||
Trash2,
|
||||
User,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { adminUsersAPI, permissionsAPI } from "../utils/api";
|
||||
|
||||
const Users = () => {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
const [resetPasswordUser, setResetPasswordUser] = useState(null)
|
||||
const queryClient = useQueryClient()
|
||||
const { user: currentUser } = useAuth()
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [resetPasswordUser, setResetPasswordUser] = useState(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
// Fetch users
|
||||
const { data: users, isLoading, error } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => adminUsersAPI.list().then(res => res.data)
|
||||
})
|
||||
const {
|
||||
data: users,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: () => adminUsersAPI.list().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Fetch available roles
|
||||
const { data: roles } = useQuery({
|
||||
queryKey: ['rolePermissions'],
|
||||
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
|
||||
})
|
||||
queryKey: ["rolePermissions"],
|
||||
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Delete user mutation
|
||||
const deleteUserMutation = useMutation({
|
||||
mutationFn: adminUsersAPI.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['users'])
|
||||
}
|
||||
})
|
||||
queryClient.invalidateQueries(["users"]);
|
||||
},
|
||||
});
|
||||
|
||||
// Update user mutation
|
||||
const updateUserMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['users'])
|
||||
setEditingUser(null)
|
||||
}
|
||||
})
|
||||
queryClient.invalidateQueries(["users"]);
|
||||
setEditingUser(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Reset password mutation
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: ({ userId, newPassword }) => adminUsersAPI.resetPassword(userId, newPassword),
|
||||
mutationFn: ({ userId, newPassword }) =>
|
||||
adminUsersAPI.resetPassword(userId, newPassword),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['users'])
|
||||
setResetPasswordUser(null)
|
||||
}
|
||||
})
|
||||
queryClient.invalidateQueries(["users"]);
|
||||
setResetPasswordUser(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteUser = async (userId, username) => {
|
||||
if (window.confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
|
||||
if (
|
||||
window.confirm(
|
||||
`Are you sure you want to delete user "${username}"? This action cannot be undone.`,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await deleteUserMutation.mutateAsync(userId)
|
||||
await deleteUserMutation.mutateAsync(userId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error)
|
||||
}
|
||||
console.error("Failed to delete user:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserCreated = () => {
|
||||
queryClient.invalidateQueries(['users'])
|
||||
setShowAddModal(false)
|
||||
}
|
||||
queryClient.invalidateQueries(["users"]);
|
||||
setShowAddModal(false);
|
||||
};
|
||||
|
||||
const handleEditUser = (user) => {
|
||||
setEditingUser(user)
|
||||
}
|
||||
setEditingUser(user);
|
||||
};
|
||||
|
||||
const handleResetPassword = (user) => {
|
||||
setResetPasswordUser(user)
|
||||
}
|
||||
setResetPasswordUser(user);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -86,12 +106,14 @@ const Users = () => {
|
||||
<div className="flex">
|
||||
<XCircle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">Error loading users</h3>
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Error loading users
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -122,23 +144,28 @@ const Users = () => {
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white">{user.username}</p>
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{user.username}
|
||||
</p>
|
||||
{user.id === currentUser?.id && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
<span className={`ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-primary-100 text-primary-800'
|
||||
: user.role === 'host_manager'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: user.role === 'readonly'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-secondary-100 text-secondary-800'
|
||||
}`}>
|
||||
<span
|
||||
className={`ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.role === "admin"
|
||||
? "bg-primary-100 text-primary-800"
|
||||
: user.role === "host_manager"
|
||||
? "bg-green-100 text-green-800"
|
||||
: user.role === "readonly"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-secondary-100 text-secondary-800"
|
||||
}`}
|
||||
>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
{user.role.charAt(0).toUpperCase() + user.role.slice(1).replace('_', ' ')}
|
||||
{user.role.charAt(0).toUpperCase() +
|
||||
user.role.slice(1).replace("_", " ")}
|
||||
</span>
|
||||
{user.is_active ? (
|
||||
<CheckCircle className="ml-2 h-4 w-4 text-green-500" />
|
||||
@@ -152,11 +179,13 @@ const Users = () => {
|
||||
</div>
|
||||
<div className="flex items-center mt-1 text-sm text-secondary-500 dark:text-secondary-300">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
Created: {new Date(user.created_at).toLocaleDateString()}
|
||||
Created:{" "}
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
{user.last_login && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
Last login: {new Date(user.last_login).toLocaleDateString()}
|
||||
Last login:{" "}
|
||||
{new Date(user.last_login).toLocaleDateString()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -188,13 +217,16 @@ const Users = () => {
|
||||
title={
|
||||
user.id === currentUser?.id
|
||||
? "Cannot delete your own account"
|
||||
: user.role === 'admin' && users.filter(u => u.role === 'admin').length === 1
|
||||
: user.role === "admin" &&
|
||||
users.filter((u) => u.role === "admin").length ===
|
||||
1
|
||||
? "Cannot delete the last admin user"
|
||||
: "Delete user"
|
||||
}
|
||||
disabled={
|
||||
user.id === currentUser?.id ||
|
||||
(user.role === 'admin' && users.filter(u => u.role === 'admin').length === 1)
|
||||
(user.role === "admin" &&
|
||||
users.filter((u) => u.role === "admin").length === 1)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -207,7 +239,9 @@ const Users = () => {
|
||||
<li>
|
||||
<div className="px-4 py-8 text-center">
|
||||
<User className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">No users found</p>
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
No users found
|
||||
</p>
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
Click "Add User" to create the first user
|
||||
</p>
|
||||
@@ -247,55 +281,61 @@ const Users = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Add User Modal Component
|
||||
const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
role: 'user'
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
role: "user",
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// Only send role if roles are available from API
|
||||
const payload = { username: formData.username, email: formData.email, password: formData.password }
|
||||
const payload = {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
};
|
||||
if (roles && Array.isArray(roles) && roles.length > 0) {
|
||||
payload.role = formData.role
|
||||
payload.role = formData.role;
|
||||
}
|
||||
const response = await adminUsersAPI.create(payload)
|
||||
onUserCreated()
|
||||
const response = await adminUsersAPI.create(payload);
|
||||
onUserCreated();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to create user')
|
||||
setError(err.response?.data?.error || "Failed to create user");
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Add New User</h3>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Add New User
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
@@ -366,7 +406,9 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">Minimum 6 characters</p>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Minimum 6 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -382,7 +424,8 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
||||
{roles && Array.isArray(roles) && roles.length > 0 ? (
|
||||
roles.map((role) => (
|
||||
<option key={role.role} value={role.role}>
|
||||
{role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
|
||||
{role.role.charAt(0).toUpperCase() +
|
||||
role.role.slice(1).replace("_", " ")}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
@@ -396,7 +439,9 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -413,57 +458,59 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create User'}
|
||||
{isLoading ? "Creating..." : "Create User"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Edit User Modal Component
|
||||
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: user?.username || '',
|
||||
email: user?.email || '',
|
||||
first_name: user?.first_name || '',
|
||||
last_name: user?.last_name || '',
|
||||
role: user?.role || 'user',
|
||||
is_active: user?.is_active ?? true
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
username: user?.username || "",
|
||||
email: user?.email || "",
|
||||
first_name: user?.first_name || "",
|
||||
last_name: user?.last_name || "",
|
||||
role: user?.role || "user",
|
||||
is_active: user?.is_active ?? true,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await adminUsersAPI.update(user.id, formData)
|
||||
onUserUpdated()
|
||||
await adminUsersAPI.update(user.id, formData);
|
||||
onUserUpdated();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to update user')
|
||||
setError(err.response?.data?.error || "Failed to update user");
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
})
|
||||
}
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen || !user) return null
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Edit User</h3>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Edit User
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
@@ -534,7 +581,8 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
||||
{roles && Array.isArray(roles) ? (
|
||||
roles.map((role) => (
|
||||
<option key={role.role} value={role.role}>
|
||||
{role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
|
||||
{role.role.charAt(0).toUpperCase() +
|
||||
role.role.slice(1).replace("_", " ")}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
@@ -561,7 +609,9 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -578,54 +628,60 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update User'}
|
||||
{isLoading ? "Updating..." : "Update User"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Reset Password Modal Component
|
||||
const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading }) => {
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const ResetPasswordModal = ({
|
||||
user,
|
||||
isOpen,
|
||||
onClose,
|
||||
onPasswordReset,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validate passwords
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters long')
|
||||
return
|
||||
setError("Password must be at least 6 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onPasswordReset({ userId: user.id, newPassword })
|
||||
await onPasswordReset({ userId: user.id, newPassword });
|
||||
// Reset form on success
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to reset password')
|
||||
}
|
||||
setError(err.response?.data?.error || "Failed to reset password");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setError('')
|
||||
onClose()
|
||||
}
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setError("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
@@ -674,7 +730,10 @@ const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading
|
||||
Password Reset Warning
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
<p>This will immediately change the user's password. The user will need to use the new password to login.</p>
|
||||
<p>
|
||||
This will immediately change the user's password. The user
|
||||
will need to use the new password to login.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -682,7 +741,9 @@ const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -699,14 +760,16 @@ const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center"
|
||||
>
|
||||
{isLoading && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>}
|
||||
{isLoading ? 'Resetting...' : 'Reset Password'}
|
||||
{isLoading && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
)}
|
||||
{isLoading ? "Resetting..." : "Reset Password"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Users
|
||||
export default Users;
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import axios from 'axios'
|
||||
import axios from "axios";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
|
||||
|
||||
// Create axios instance with default config
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// Add auth token if available
|
||||
const token = localStorage.getItem('token')
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
api.interceptors.response.use(
|
||||
@@ -32,200 +32,235 @@ api.interceptors.response.use(
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Don't redirect if we're on the login page or if it's a TFA verification error
|
||||
const currentPath = window.location.pathname
|
||||
const isTfaError = error.config?.url?.includes('/verify-tfa')
|
||||
const currentPath = window.location.pathname;
|
||||
const isTfaError = error.config?.url?.includes("/verify-tfa");
|
||||
|
||||
if (currentPath !== '/login' && !isTfaError) {
|
||||
if (currentPath !== "/login" && !isTfaError) {
|
||||
// Handle unauthorized
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('permissions')
|
||||
window.location.href = '/login'
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
localStorage.removeItem("permissions");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// Dashboard API
|
||||
export const dashboardAPI = {
|
||||
getStats: () => api.get('/dashboard/stats'),
|
||||
getHosts: () => api.get('/dashboard/hosts'),
|
||||
getPackages: () => api.get('/dashboard/packages'),
|
||||
getStats: () => api.get("/dashboard/stats"),
|
||||
getHosts: () => api.get("/dashboard/hosts"),
|
||||
getPackages: () => api.get("/dashboard/packages"),
|
||||
getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
|
||||
getRecentUsers: () => api.get('/dashboard/recent-users'),
|
||||
getRecentCollection: () => api.get('/dashboard/recent-collection')
|
||||
}
|
||||
getRecentUsers: () => api.get("/dashboard/recent-users"),
|
||||
getRecentCollection: () => api.get("/dashboard/recent-collection"),
|
||||
};
|
||||
|
||||
// Admin Hosts API (for management interface)
|
||||
export const adminHostsAPI = {
|
||||
create: (data) => api.post('/hosts/create', data),
|
||||
list: () => api.get('/hosts/admin/list'),
|
||||
create: (data) => api.post("/hosts/create", data),
|
||||
list: () => api.get("/hosts/admin/list"),
|
||||
delete: (hostId) => api.delete(`/hosts/${hostId}`),
|
||||
deleteBulk: (hostIds) => api.delete('/hosts/bulk', { data: { hostIds } }),
|
||||
regenerateCredentials: (hostId) => api.post(`/hosts/${hostId}/regenerate-credentials`),
|
||||
updateGroup: (hostId, hostGroupId) => api.put(`/hosts/${hostId}/group`, { hostGroupId }),
|
||||
bulkUpdateGroup: (hostIds, hostGroupId) => api.put('/hosts/bulk/group', { hostIds, hostGroupId }),
|
||||
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
|
||||
updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendly_name: friendlyName })
|
||||
}
|
||||
deleteBulk: (hostIds) => api.delete("/hosts/bulk", { data: { hostIds } }),
|
||||
regenerateCredentials: (hostId) =>
|
||||
api.post(`/hosts/${hostId}/regenerate-credentials`),
|
||||
updateGroup: (hostId, hostGroupId) =>
|
||||
api.put(`/hosts/${hostId}/group`, { hostGroupId }),
|
||||
bulkUpdateGroup: (hostIds, hostGroupId) =>
|
||||
api.put("/hosts/bulk/group", { hostIds, hostGroupId }),
|
||||
toggleAutoUpdate: (hostId, autoUpdate) =>
|
||||
api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
|
||||
updateFriendlyName: (hostId, friendlyName) =>
|
||||
api.patch(`/hosts/${hostId}/friendly-name`, {
|
||||
friendly_name: friendlyName,
|
||||
}),
|
||||
};
|
||||
|
||||
// Host Groups API
|
||||
export const hostGroupsAPI = {
|
||||
list: () => api.get('/host-groups'),
|
||||
list: () => api.get("/host-groups"),
|
||||
get: (id) => api.get(`/host-groups/${id}`),
|
||||
create: (data) => api.post('/host-groups', data),
|
||||
create: (data) => api.post("/host-groups", data),
|
||||
update: (id, data) => api.put(`/host-groups/${id}`, data),
|
||||
delete: (id) => api.delete(`/host-groups/${id}`),
|
||||
getHosts: (id) => api.get(`/host-groups/${id}/hosts`),
|
||||
}
|
||||
};
|
||||
|
||||
// Admin Users API (for user management)
|
||||
export const adminUsersAPI = {
|
||||
list: () => api.get('/auth/admin/users'),
|
||||
create: (userData) => api.post('/auth/admin/users', userData),
|
||||
update: (userId, userData) => api.put(`/auth/admin/users/${userId}`, userData),
|
||||
list: () => api.get("/auth/admin/users"),
|
||||
create: (userData) => api.post("/auth/admin/users", userData),
|
||||
update: (userId, userData) =>
|
||||
api.put(`/auth/admin/users/${userId}`, userData),
|
||||
delete: (userId) => api.delete(`/auth/admin/users/${userId}`),
|
||||
resetPassword: (userId, newPassword) => api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword })
|
||||
}
|
||||
resetPassword: (userId, newPassword) =>
|
||||
api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword }),
|
||||
};
|
||||
|
||||
// Permissions API (for role management)
|
||||
export const permissionsAPI = {
|
||||
getRoles: () => api.get('/permissions/roles'),
|
||||
getRoles: () => api.get("/permissions/roles"),
|
||||
getRole: (role) => api.get(`/permissions/roles/${role}`),
|
||||
updateRole: (role, permissions) => api.put(`/permissions/roles/${role}`, permissions),
|
||||
updateRole: (role, permissions) =>
|
||||
api.put(`/permissions/roles/${role}`, permissions),
|
||||
deleteRole: (role) => api.delete(`/permissions/roles/${role}`),
|
||||
getUserPermissions: () => api.get('/permissions/user-permissions')
|
||||
}
|
||||
getUserPermissions: () => api.get("/permissions/user-permissions"),
|
||||
};
|
||||
|
||||
// Settings API
|
||||
export const settingsAPI = {
|
||||
get: () => api.get('/settings'),
|
||||
update: (settings) => api.put('/settings', settings),
|
||||
getServerUrl: () => api.get('/settings/server-url')
|
||||
}
|
||||
get: () => api.get("/settings"),
|
||||
update: (settings) => api.put("/settings", settings),
|
||||
getServerUrl: () => api.get("/settings/server-url"),
|
||||
};
|
||||
|
||||
// Agent Version API
|
||||
export const agentVersionAPI = {
|
||||
list: () => api.get('/hosts/agent/versions'),
|
||||
create: (data) => api.post('/hosts/agent/versions', data),
|
||||
list: () => api.get("/hosts/agent/versions"),
|
||||
create: (data) => api.post("/hosts/agent/versions", data),
|
||||
update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data),
|
||||
delete: (id) => api.delete(`/hosts/agent/versions/${id}`),
|
||||
setCurrent: (id) => api.patch(`/hosts/agent/versions/${id}/current`),
|
||||
setDefault: (id) => api.patch(`/hosts/agent/versions/${id}/default`),
|
||||
download: (version) => api.get(`/hosts/agent/download${version ? `?version=${version}` : ''}`, { responseType: 'blob' })
|
||||
}
|
||||
download: (version) =>
|
||||
api.get(`/hosts/agent/download${version ? `?version=${version}` : ""}`, {
|
||||
responseType: "blob",
|
||||
}),
|
||||
};
|
||||
|
||||
// Repository API
|
||||
export const repositoryAPI = {
|
||||
list: () => api.get('/repositories'),
|
||||
list: () => api.get("/repositories"),
|
||||
getById: (repositoryId) => api.get(`/repositories/${repositoryId}`),
|
||||
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
|
||||
update: (repositoryId, data) => api.put(`/repositories/${repositoryId}`, data),
|
||||
update: (repositoryId, data) =>
|
||||
api.put(`/repositories/${repositoryId}`, data),
|
||||
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
|
||||
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, { isEnabled }),
|
||||
getStats: () => api.get('/repositories/stats/summary'),
|
||||
cleanupOrphaned: () => api.delete('/repositories/cleanup/orphaned')
|
||||
}
|
||||
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, {
|
||||
isEnabled,
|
||||
}),
|
||||
getStats: () => api.get("/repositories/stats/summary"),
|
||||
cleanupOrphaned: () => api.delete("/repositories/cleanup/orphaned"),
|
||||
};
|
||||
|
||||
// Dashboard Preferences API
|
||||
export const dashboardPreferencesAPI = {
|
||||
get: () => api.get('/dashboard-preferences'),
|
||||
update: (preferences) => api.put('/dashboard-preferences', { preferences }),
|
||||
getDefaults: () => api.get('/dashboard-preferences/defaults')
|
||||
}
|
||||
get: () => api.get("/dashboard-preferences"),
|
||||
update: (preferences) => api.put("/dashboard-preferences", { preferences }),
|
||||
getDefaults: () => api.get("/dashboard-preferences/defaults"),
|
||||
};
|
||||
|
||||
// Hosts API (for agent communication - kept for compatibility)
|
||||
export const hostsAPI = {
|
||||
// Legacy register endpoint (now deprecated)
|
||||
register: (data) => api.post('/hosts/register', data),
|
||||
register: (data) => api.post("/hosts/register", data),
|
||||
|
||||
// Updated to use API credentials
|
||||
update: (apiId, apiKey, data) => api.post('/hosts/update', data, {
|
||||
update: (apiId, apiKey, data) =>
|
||||
api.post("/hosts/update", data, {
|
||||
headers: {
|
||||
'X-API-ID': apiId,
|
||||
'X-API-KEY': apiKey
|
||||
}
|
||||
"X-API-ID": apiId,
|
||||
"X-API-KEY": apiKey,
|
||||
},
|
||||
}),
|
||||
getInfo: (apiId, apiKey) => api.get('/hosts/info', {
|
||||
getInfo: (apiId, apiKey) =>
|
||||
api.get("/hosts/info", {
|
||||
headers: {
|
||||
'X-API-ID': apiId,
|
||||
'X-API-KEY': apiKey
|
||||
}
|
||||
"X-API-ID": apiId,
|
||||
"X-API-KEY": apiKey,
|
||||
},
|
||||
}),
|
||||
ping: (apiId, apiKey) => api.post('/hosts/ping', {}, {
|
||||
ping: (apiId, apiKey) =>
|
||||
api.post(
|
||||
"/hosts/ping",
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-API-ID': apiId,
|
||||
'X-API-KEY': apiKey
|
||||
}
|
||||
}),
|
||||
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate })
|
||||
}
|
||||
"X-API-ID": apiId,
|
||||
"X-API-KEY": apiKey,
|
||||
},
|
||||
},
|
||||
),
|
||||
toggleAutoUpdate: (id, autoUpdate) =>
|
||||
api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate }),
|
||||
};
|
||||
|
||||
// Packages API
|
||||
export const packagesAPI = {
|
||||
getAll: (params = {}) => api.get('/packages', { params }),
|
||||
getAll: (params = {}) => api.get("/packages", { params }),
|
||||
getById: (packageId) => api.get(`/packages/${packageId}`),
|
||||
getCategories: () => api.get('/packages/categories/list'),
|
||||
getHosts: (packageId, params = {}) => api.get(`/packages/${packageId}/hosts`, { params }),
|
||||
getCategories: () => api.get("/packages/categories/list"),
|
||||
getHosts: (packageId, params = {}) =>
|
||||
api.get(`/packages/${packageId}/hosts`, { params }),
|
||||
update: (packageId, data) => api.put(`/packages/${packageId}`, data),
|
||||
search: (query, params = {}) => api.get(`/packages/search/${query}`, { params }),
|
||||
}
|
||||
search: (query, params = {}) =>
|
||||
api.get(`/packages/search/${query}`, { params }),
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
export const formatError = (error) => {
|
||||
if (error.response?.data?.message) {
|
||||
return error.response.data.message
|
||||
return error.response.data.message;
|
||||
}
|
||||
if (error.response?.data?.error) {
|
||||
return error.response.data.error
|
||||
return error.response.data.error;
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message
|
||||
return error.message;
|
||||
}
|
||||
return 'An unexpected error occurred'
|
||||
}
|
||||
return "An unexpected error occurred";
|
||||
};
|
||||
|
||||
export const formatDate = (date) => {
|
||||
return new Date(date).toLocaleString()
|
||||
}
|
||||
return new Date(date).toLocaleString();
|
||||
};
|
||||
|
||||
// Version API
|
||||
export const versionAPI = {
|
||||
getCurrent: () => api.get('/version/current'),
|
||||
checkUpdates: () => api.get('/version/check-updates'),
|
||||
testSshKey: (data) => api.post('/version/test-ssh-key', data),
|
||||
}
|
||||
getCurrent: () => api.get("/version/current"),
|
||||
checkUpdates: () => api.get("/version/check-updates"),
|
||||
testSshKey: (data) => api.post("/version/test-ssh-key", data),
|
||||
};
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
login: (username, password) => api.post('/auth/login', { username, password }),
|
||||
verifyTfa: (username, token) => api.post('/auth/verify-tfa', { username, token }),
|
||||
signup: (username, email, password, firstName, lastName) => api.post('/auth/signup', { username, email, password, firstName, lastName }),
|
||||
}
|
||||
login: (username, password) =>
|
||||
api.post("/auth/login", { username, password }),
|
||||
verifyTfa: (username, token) =>
|
||||
api.post("/auth/verify-tfa", { username, token }),
|
||||
signup: (username, email, password, firstName, lastName) =>
|
||||
api.post("/auth/signup", {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
}),
|
||||
};
|
||||
|
||||
// TFA API
|
||||
export const tfaAPI = {
|
||||
setup: () => api.get('/tfa/setup'),
|
||||
verifySetup: (data) => api.post('/tfa/verify-setup', data),
|
||||
disable: (data) => api.post('/tfa/disable', data),
|
||||
status: () => api.get('/tfa/status'),
|
||||
regenerateBackupCodes: () => api.post('/tfa/regenerate-backup-codes'),
|
||||
verify: (data) => api.post('/tfa/verify', data),
|
||||
}
|
||||
setup: () => api.get("/tfa/setup"),
|
||||
verifySetup: (data) => api.post("/tfa/verify-setup", data),
|
||||
disable: (data) => api.post("/tfa/disable", data),
|
||||
status: () => api.get("/tfa/status"),
|
||||
regenerateBackupCodes: () => api.post("/tfa/regenerate-backup-codes"),
|
||||
verify: (data) => api.post("/tfa/verify", data),
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (date) => {
|
||||
const now = new Date()
|
||||
const diff = now - new Date(date)
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
const now = new Date();
|
||||
const diff = now - new Date(date);
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
|
||||
return `${seconds} second${seconds > 1 ? 's' : ''} ago`
|
||||
}
|
||||
if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
|
||||
return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
|
||||
};
|
||||
|
||||
export default api
|
||||
export default api;
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
import {
|
||||
Cpu,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Monitor,
|
||||
Server,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Zap,
|
||||
Shield,
|
||||
Globe,
|
||||
Terminal
|
||||
} from 'lucide-react';
|
||||
|
||||
Terminal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { DiDebian, DiLinux, DiUbuntu, DiWindows } from "react-icons/di";
|
||||
// Import OS icons from react-icons
|
||||
import {
|
||||
SiUbuntu,
|
||||
SiDebian,
|
||||
SiCentos,
|
||||
SiFedora,
|
||||
SiArchlinux,
|
||||
SiAlpinelinux,
|
||||
SiArchlinux,
|
||||
SiCentos,
|
||||
SiDebian,
|
||||
SiFedora,
|
||||
SiLinux,
|
||||
SiMacos
|
||||
} from 'react-icons/si';
|
||||
|
||||
import {
|
||||
DiUbuntu,
|
||||
DiDebian,
|
||||
DiLinux,
|
||||
DiWindows
|
||||
} from 'react-icons/di';
|
||||
SiMacos,
|
||||
SiUbuntu,
|
||||
} from "react-icons/si";
|
||||
|
||||
/**
|
||||
* OS Icon mapping utility
|
||||
@@ -38,25 +31,26 @@ export const getOSIcon = (osType) => {
|
||||
const os = osType.toLowerCase();
|
||||
|
||||
// Linux distributions with authentic react-icons
|
||||
if (os.includes('ubuntu')) return SiUbuntu;
|
||||
if (os.includes('debian')) return SiDebian;
|
||||
if (os.includes('centos') || os.includes('rhel') || os.includes('red hat')) return SiCentos;
|
||||
if (os.includes('fedora')) return SiFedora;
|
||||
if (os.includes('arch')) return SiArchlinux;
|
||||
if (os.includes('alpine')) return SiAlpinelinux;
|
||||
if (os.includes('suse') || os.includes('opensuse')) return SiLinux; // SUSE uses generic Linux icon
|
||||
if (os.includes("ubuntu")) return SiUbuntu;
|
||||
if (os.includes("debian")) return SiDebian;
|
||||
if (os.includes("centos") || os.includes("rhel") || os.includes("red hat"))
|
||||
return SiCentos;
|
||||
if (os.includes("fedora")) return SiFedora;
|
||||
if (os.includes("arch")) return SiArchlinux;
|
||||
if (os.includes("alpine")) return SiAlpinelinux;
|
||||
if (os.includes("suse") || os.includes("opensuse")) return SiLinux; // SUSE uses generic Linux icon
|
||||
|
||||
// Generic Linux
|
||||
if (os.includes('linux')) return SiLinux;
|
||||
if (os.includes("linux")) return SiLinux;
|
||||
|
||||
// Windows
|
||||
if (os.includes('windows')) return DiWindows;
|
||||
if (os.includes("windows")) return DiWindows;
|
||||
|
||||
// macOS
|
||||
if (os.includes('mac') || os.includes('darwin')) return SiMacos;
|
||||
if (os.includes("mac") || os.includes("darwin")) return SiMacos;
|
||||
|
||||
// FreeBSD
|
||||
if (os.includes('freebsd')) return Server;
|
||||
if (os.includes("freebsd")) return Server;
|
||||
|
||||
// Default fallback
|
||||
return Monitor;
|
||||
@@ -67,11 +61,11 @@ export const getOSIcon = (osType) => {
|
||||
* Maps operating system types to appropriate colors (react-icons have built-in brand colors)
|
||||
*/
|
||||
export const getOSColor = (osType) => {
|
||||
if (!osType) return 'text-gray-500';
|
||||
if (!osType) return "text-gray-500";
|
||||
|
||||
// react-icons already have the proper brand colors built-in
|
||||
// This function is kept for compatibility but returns neutral colors
|
||||
return 'text-gray-600';
|
||||
return "text-gray-600";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -79,32 +73,33 @@ export const getOSColor = (osType) => {
|
||||
* Provides clean, formatted OS names for display
|
||||
*/
|
||||
export const getOSDisplayName = (osType) => {
|
||||
if (!osType) return 'Unknown';
|
||||
if (!osType) return "Unknown";
|
||||
|
||||
const os = osType.toLowerCase();
|
||||
|
||||
// Linux distributions
|
||||
if (os.includes('ubuntu')) return 'Ubuntu';
|
||||
if (os.includes('debian')) return 'Debian';
|
||||
if (os.includes('centos')) return 'CentOS';
|
||||
if (os.includes('rhel') || os.includes('red hat')) return 'Red Hat Enterprise Linux';
|
||||
if (os.includes('fedora')) return 'Fedora';
|
||||
if (os.includes('arch')) return 'Arch Linux';
|
||||
if (os.includes('suse')) return 'SUSE Linux';
|
||||
if (os.includes('opensuse')) return 'openSUSE';
|
||||
if (os.includes('alpine')) return 'Alpine Linux';
|
||||
if (os.includes("ubuntu")) return "Ubuntu";
|
||||
if (os.includes("debian")) return "Debian";
|
||||
if (os.includes("centos")) return "CentOS";
|
||||
if (os.includes("rhel") || os.includes("red hat"))
|
||||
return "Red Hat Enterprise Linux";
|
||||
if (os.includes("fedora")) return "Fedora";
|
||||
if (os.includes("arch")) return "Arch Linux";
|
||||
if (os.includes("suse")) return "SUSE Linux";
|
||||
if (os.includes("opensuse")) return "openSUSE";
|
||||
if (os.includes("alpine")) return "Alpine Linux";
|
||||
|
||||
// Generic Linux
|
||||
if (os.includes('linux')) return 'Linux';
|
||||
if (os.includes("linux")) return "Linux";
|
||||
|
||||
// Windows
|
||||
if (os.includes('windows')) return 'Windows';
|
||||
if (os.includes("windows")) return "Windows";
|
||||
|
||||
// macOS
|
||||
if (os.includes('mac') || os.includes('darwin')) return 'macOS';
|
||||
if (os.includes("mac") || os.includes("darwin")) return "macOS";
|
||||
|
||||
// FreeBSD
|
||||
if (os.includes('freebsd')) return 'FreeBSD';
|
||||
if (os.includes("freebsd")) return "FreeBSD";
|
||||
|
||||
// Return original if no match
|
||||
return osType;
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
secondary: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
50: "#f8fafc",
|
||||
100: "#f1f5f9",
|
||||
200: "#e2e8f0",
|
||||
300: "#cbd5e1",
|
||||
400: "#94a3b8",
|
||||
500: "#64748b",
|
||||
600: "#475569",
|
||||
700: "#334155",
|
||||
800: "#1e293b",
|
||||
900: "#0f172a",
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
50: "#f0fdf4",
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
50: "#fffbeb",
|
||||
100: "#fef3c7",
|
||||
200: "#fde68a",
|
||||
300: "#fcd34d",
|
||||
400: "#fbbf24",
|
||||
500: "#f59e0b",
|
||||
600: "#d97706",
|
||||
700: "#b45309",
|
||||
800: "#92400e",
|
||||
900: "#78350f",
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
50: "#fef2f2",
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
|
||||
sans: ["Inter", "ui-sans-serif", "system-ui"],
|
||||
mono: ["JetBrains Mono", "ui-monospace", "monospace"],
|
||||
},
|
||||
boxShadow: {
|
||||
'card': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
'card-hover': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
'card-dark': '0 1px 3px 0 rgba(255, 255, 255, 0.1), 0 1px 2px 0 rgba(255, 255, 255, 0.06)',
|
||||
'card-hover-dark': '0 4px 6px -1px rgba(255, 255, 255, 0.15), 0 2px 4px -1px rgba(255, 255, 255, 0.1)',
|
||||
card: "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)",
|
||||
"card-hover":
|
||||
"0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
||||
"card-dark":
|
||||
"0 1px 3px 0 rgba(255, 255, 255, 0.1), 0 1px 2px 0 rgba(255, 255, 255, 0.06)",
|
||||
"card-hover-dark":
|
||||
"0 4px 6px -1px rgba(255, 255, 255, 0.15), 0 2px 4px -1px rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,35 +1,47 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: "0.0.0.0", // Listen on all interfaces
|
||||
strictPort: true, // Exit if port is already in use
|
||||
allowedHosts: ['localhost'],
|
||||
allowedHosts: true, // Allow all hosts in development
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
"/api": {
|
||||
target: `http://${process.env.BACKEND_HOST}:${process.env.BACKEND_PORT}`,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: process.env.VITE_ENABLE_LOGGING === 'true' ? (proxy, options) => {
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.log('proxy error', err);
|
||||
configure:
|
||||
process.env.VITE_ENABLE_LOGGING === "true"
|
||||
? (proxy, options) => {
|
||||
proxy.on("error", (err, req, res) => {
|
||||
console.log("proxy error", err);
|
||||
});
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
console.log('Sending Request to the Target:', req.method, req.url);
|
||||
proxy.on("proxyReq", (proxyReq, req, res) => {
|
||||
console.log(
|
||||
"Sending Request to the Target:",
|
||||
req.method,
|
||||
req.url,
|
||||
);
|
||||
});
|
||||
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
|
||||
proxy.on("proxyRes", (proxyRes, req, res) => {
|
||||
console.log(
|
||||
"Received Response from the Target:",
|
||||
proxyRes.statusCode,
|
||||
req.url,
|
||||
);
|
||||
});
|
||||
} : undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: process.env.NODE_ENV !== 'production',
|
||||
target: 'es2018',
|
||||
outDir: "dist",
|
||||
sourcemap: process.env.NODE_ENV !== "production",
|
||||
target: "es2018",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user