style(frontend): fmt

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

View File

@@ -1,49 +1,49 @@
{
"name": "patchmon-frontend",
"private": true,
"version": "1.2.6",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.87.4",
"axios": "^1.7.9",
"chart.js": "^4.4.7",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"vite": "^7.1.5"
},
"overrides": {
"esbuild": "^0.25.10"
}
"name": "patchmon-frontend",
"private": true,
"version": "1.2.6",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.87.4",
"axios": "^1.7.9",
"chart.js": "^4.4.7",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"vite": "^7.1.5"
},
"overrides": {
"esbuild": "^0.25.10"
}
}

View File

@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,45 +1,50 @@
import express from 'express';
import path from 'path';
import cors from 'cors';
import { fileURLToPath } from 'url';
import { createProxyMiddleware } from 'http-proxy-middleware';
import cors from "cors";
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend:3001';
const BACKEND_URL = process.env.BACKEND_URL || "http://backend:3001";
// Enable CORS for API calls
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true
}));
app.use(
cors({
origin: process.env.CORS_ORIGIN || "*",
credentials: true,
}),
);
// Proxy API requests to backend
app.use('/api', createProxyMiddleware({
target: BACKEND_URL,
changeOrigin: true,
logLevel: 'info',
onError: (err, req, res) => {
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}`);
}
}));
app.use(
"/api",
createProxyMiddleware({
target: BACKEND_URL,
changeOrigin: true,
logLevel: "info",
onError: (err, req, res) => {
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(`Frontend server running on port ${PORT}`);
console.log(`Serving from: ${path.join(__dirname, "dist")}`);
});

View File

@@ -1,147 +1,185 @@
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) {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
<div className="text-center">
<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>
</div>
</div>
)
}
// Show loading while checking if setup is needed
if (checkingSetup) {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
<div className="text-center">
<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>
</div>
</div>
);
}
// Show first-time setup if no admin users exist
if (needsFirstTimeSetup && !isAuth) {
return <FirstTimeAdminSetup />
}
// Show first-time setup if no admin users exist
if (needsFirstTimeSetup && !isAuth) {
return <FirstTimeAdminSetup />;
}
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute requirePermission="can_view_dashboard">
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts" element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Hosts />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts/:hostId" element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<HostDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/packages" element={
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<Packages />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories" element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Repositories />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories/:repositoryId" element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<RepositoryDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/users" element={
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<Users />
</Layout>
</ProtectedRoute>
} />
<Route path="/permissions" element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Permissions />
</Layout>
</ProtectedRoute>
} />
<Route path="/settings" element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Settings />
</Layout>
</ProtectedRoute>
} />
<Route path="/options" element={
<ProtectedRoute requirePermission="can_manage_hosts">
<Layout>
<Options />
</Layout>
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<Layout>
<Profile />
</Layout>
</ProtectedRoute>
} />
<Route path="/packages/:packageId" element={
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<PackageDetail />
</Layout>
</ProtectedRoute>
} />
</Routes>
)
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute requirePermission="can_view_dashboard">
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/hosts"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Hosts />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/hosts/:hostId"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<HostDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/packages"
element={
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<Packages />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/repositories"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Repositories />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/repositories/:repositoryId"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<RepositoryDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/users"
element={
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<Users />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/permissions"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Permissions />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Settings />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/options"
element={
<ProtectedRoute requirePermission="can_manage_hosts">
<Layout>
<Options />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Layout>
<Profile />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/packages/:packageId"
element={
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<PackageDetail />
</Layout>
</ProtectedRoute>
}
/>
</Routes>
);
}
function App() {
return (
<ThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<AppRoutes />
</UpdateNotificationProvider>
</AuthProvider>
</ThemeProvider>
)
return (
<ThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<AppRoutes />
</UpdateNotificationProvider>
</AuthProvider>
</ThemeProvider>
);
}
export default App
export default App;

View File

@@ -1,336 +1,359 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
X,
GripVertical,
Eye,
EyeOff,
Save,
RotateCcw,
Settings as SettingsIcon
} from 'lucide-react';
import { dashboardPreferencesAPI } from '../utils/api';
import { useTheme } from '../contexts/ThemeContext';
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Eye,
EyeOff,
GripVertical,
RotateCcw,
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 }) => {
const { isDark } = useTheme();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: card.cardId });
const { isDark } = useTheme();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: card.cardId });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
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'
}`}
>
<div className="flex items-center gap-3">
<button
{...attributes}
{...listeners}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-secondary-900 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>
) : null}
</div>
</div>
</div>
<button
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'
}`}
>
{card.enabled ? (
<>
<Eye className="h-3 w-3" />
Visible
</>
) : (
<>
<EyeOff className="h-3 w-3" />
Hidden
</>
)}
</button>
</div>
);
return (
<div
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"
}`}
>
<div className="flex items-center gap-3">
<button
{...attributes}
{...listeners}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-secondary-900 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>
) : null}
</div>
</div>
</div>
<button
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"
}`}
>
{card.enabled ? (
<>
<Eye className="h-3 w-3" />
Visible
</>
) : (
<>
<EyeOff className="h-3 w-3" />
Hidden
</>
)}
</button>
</div>
);
};
const DashboardSettingsModal = ({ isOpen, onClose }) => {
const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const { isDark } = useTheme();
const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const { isDark } = useTheme();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const sensors = useSensors(
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
});
// Fetch user's dashboard preferences
const { data: preferences, isLoading } = useQuery({
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
});
// Fetch default card configuration
const { data: defaultCards } = useQuery({
queryKey: ["dashboardDefaultCards"],
queryFn: () =>
dashboardPreferencesAPI.getDefaults().then((res) => res.data),
enabled: isOpen,
});
// Update preferences mutation
const updatePreferencesMutation = useMutation({
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
onSuccess: (response) => {
// Optimistically update the query cache with the correct data structure
queryClient.setQueryData(['dashboardPreferences'], response.data.preferences);
// Also invalidate to ensure fresh data
queryClient.invalidateQueries(['dashboardPreferences']);
setHasChanges(false);
onClose();
},
onError: (error) => {
console.error('Failed to update dashboard preferences:', error);
}
});
// Update preferences mutation
const updatePreferencesMutation = useMutation({
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
onSuccess: (response) => {
// Optimistically update the query cache with the correct data structure
queryClient.setQueryData(
["dashboardPreferences"],
response.data.preferences,
);
// Also invalidate to ensure fresh data
queryClient.invalidateQueries(["dashboardPreferences"]);
setHasChanges(false);
onClose();
},
onError: (error) => {
console.error("Failed to update dashboard preferences:", error);
},
});
// Initialize cards when preferences or defaults are loaded
useEffect(() => {
if (preferences && defaultCards) {
// Normalize server preferences (snake_case -> camelCase)
const normalizedPreferences = preferences.map((p) => ({
cardId: p.cardId ?? p.card_id,
enabled: p.enabled,
order: p.order,
}));
// Initialize cards when preferences or defaults are loaded
useEffect(() => {
if (preferences && defaultCards) {
// Normalize server preferences (snake_case -> camelCase)
const normalizedPreferences = preferences.map((p) => ({
cardId: p.cardId ?? p.card_id,
enabled: p.enabled,
order: p.order,
}));
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';
return undefined;
};
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";
return undefined;
};
// Merge user preferences with default cards
const mergedCards = defaultCards
.map((defaultCard) => {
const userPreference = normalizedPreferences.find(
(p) => p.cardId === defaultCard.cardId
);
return {
...defaultCard,
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order,
typeLabel: typeLabelFor(defaultCard.cardId),
};
})
.sort((a, b) => a.order - b.order);
setCards(mergedCards);
}
}, [preferences, defaultCards]);
// Merge user preferences with default cards
const mergedCards = defaultCards
.map((defaultCard) => {
const userPreference = normalizedPreferences.find(
(p) => p.cardId === defaultCard.cardId,
);
return {
...defaultCard,
enabled: userPreference
? userPreference.enabled
: defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order,
typeLabel: typeLabelFor(defaultCard.cardId),
};
})
.sort((a, b) => a.order - b.order);
const handleDragEnd = (event) => {
const { active, over } = event;
setCards(mergedCards);
}
}, [preferences, defaultCards]);
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 newItems = arrayMove(items, oldIndex, newIndex);
// Update order values
return newItems.map((item, index) => ({
...item,
order: index
}));
});
setHasChanges(true);
}
};
const handleDragEnd = (event) => {
const { active, over } = event;
const handleToggle = (cardId) => {
setCards(prevCards =>
prevCards.map(card =>
card.cardId === cardId
? { ...card, enabled: !card.enabled }
: card
)
);
setHasChanges(true);
};
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 handleSave = () => {
const preferences = cards.map(card => ({
cardId: card.cardId,
enabled: card.enabled,
order: card.order
}));
updatePreferencesMutation.mutate(preferences);
};
const newItems = arrayMove(items, oldIndex, newIndex);
const handleReset = () => {
if (defaultCards) {
const resetCards = defaultCards.map(card => ({
...card,
enabled: true,
order: card.order
}));
setCards(resetCards);
setHasChanges(true);
}
};
// Update order values
return newItems.map((item, index) => ({
...item,
order: index,
}));
});
setHasChanges(true);
}
};
if (!isOpen) return null;
const handleToggle = (cardId) => {
setCards((prevCards) =>
prevCards.map((card) =>
card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
),
);
setHasChanges(true);
};
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="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<SettingsIcon className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Dashboard Settings
</h3>
</div>
<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>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Customize your dashboard by reordering cards and toggling their visibility.
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
</p>
const handleSave = () => {
const preferences = cards.map((card) => ({
cardId: card.cardId,
enabled: card.enabled,
order: card.order,
}));
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={cards.map(card => card.cardId)} strategy={verticalListSortingStrategy}>
<div className="space-y-2 max-h-96 overflow-y-auto">
{cards.map((card) => (
<SortableCardItem
key={card.cardId}
card={card}
onToggle={handleToggle}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending}
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'
}`}
>
{updatePreferencesMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</button>
<button
onClick={handleReset}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</button>
<button
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
updatePreferencesMutation.mutate(preferences);
};
const handleReset = () => {
if (defaultCards) {
const resetCards = defaultCards.map((card) => ({
...card,
enabled: true,
order: card.order,
}));
setCards(resetCards);
setHasChanges(true);
}
};
if (!isOpen) return null;
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="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<SettingsIcon className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Dashboard Settings
</h3>
</div>
<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>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Customize your dashboard by reordering cards and toggling their
visibility. Drag cards to reorder them, and click the visibility
toggle to show/hide cards.
</p>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={cards.map((card) => card.cardId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 max-h-96 overflow-y-auto">
{cards.map((card) => (
<SortableCardItem
key={card.cardId}
card={card}
onToggle={handleToggle}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending}
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"
}`}
>
{updatePreferencesMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</button>
<button
onClick={handleReset}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</button>
<button
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
};
export default DashboardSettingsModal;

View File

@@ -1,297 +1,321 @@
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 [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: ''
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
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);
const handleInputChange = (e) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value
}))
// Clear error when user starts typing
if (error) setError('')
}
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
// Clear error when user starts typing
if (error) setError("");
};
const validateForm = () => {
if (!formData.firstName.trim()) {
setError('First name is required')
return false
}
if (!formData.lastName.trim()) {
setError('Last name is required')
return false
}
if (!formData.username.trim()) {
setError('Username is required')
return false
}
if (!formData.email.trim()) {
setError('Email address is required')
return false
}
// Enhanced email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(formData.email.trim())) {
setError('Please enter a valid email address (e.g., user@example.com)')
return false
}
if (formData.password.length < 8) {
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
}
const validateForm = () => {
if (!formData.firstName.trim()) {
setError("First name is required");
return false;
}
if (!formData.lastName.trim()) {
setError("Last name is required");
return false;
}
if (!formData.username.trim()) {
setError("Username is required");
return false;
}
if (!formData.email.trim()) {
setError("Email address is required");
return false;
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!validateForm()) return
// Enhanced email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email.trim())) {
setError("Please enter a valid email address (e.g., user@example.com)");
return false;
}
setIsLoading(true)
setError('')
if (formData.password.length < 8) {
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;
};
try {
const response = await fetch('/api/v1/auth/setup-admin', {
method: 'POST',
headers: {
'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()
})
})
const handleSubmit = async (e) => {
e.preventDefault();
const data = await response.json()
if (!validateForm()) return;
if (response.ok) {
setSuccess(true)
// Auto-login the user after successful setup
setTimeout(() => {
login(formData.username.trim(), formData.password)
}, 2000)
} else {
setError(data.error || 'Failed to create admin user')
}
} catch (error) {
console.error('Setup error:', error)
setError('Network error. Please try again.')
} finally {
setIsLoading(false)
}
}
setIsLoading(true);
setError("");
if (success) {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="card p-8 text-center">
<div className="flex justify-center mb-6">
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-full">
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-4">
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.
</p>
<div className="flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
</div>
</div>
</div>
)
}
try {
const response = await fetch("/api/v1/auth/setup-admin", {
method: "POST",
headers: {
"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(),
}),
});
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="card p-8">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="bg-primary-100 dark:bg-primary-900 p-4 rounded-full">
<Shield className="h-12 w-12 text-primary-600 dark:text-primary-400" />
</div>
</div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
Welcome to PatchMon
</h1>
<p className="text-secondary-600 dark:text-secondary-300">
Let's set up your admin account to get started
</p>
</div>
const data = await response.json();
{error && (
<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>
</div>
</div>
)}
if (response.ok) {
setSuccess(true);
// Auto-login the user after successful setup
setTimeout(() => {
login(formData.username.trim(), formData.password);
}, 2000);
} else {
setError(data.error || "Failed to create admin user");
}
} catch (error) {
console.error("Setup error:", error);
setError("Network error. Please try again.");
} finally {
setIsLoading(false);
}
};
<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">
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your first name"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your last name"
required
disabled={isLoading}
/>
</div>
</div>
if (success) {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="card p-8 text-center">
<div className="flex justify-center mb-6">
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-full">
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-4">
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.
</p>
<div className="flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
</div>
</div>
</div>
);
}
<div>
<label htmlFor="username" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Username
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="card p-8">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="bg-primary-100 dark:bg-primary-900 p-4 rounded-full">
<Shield className="h-12 w-12 text-primary-600 dark:text-primary-400" />
</div>
</div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
Welcome to PatchMon
</h1>
<p className="text-secondary-600 dark:text-secondary-300">
Let's set up your admin account to get started
</p>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Email Address
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your email"
required
disabled={isLoading}
/>
</div>
{error && (
<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>
</div>
</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Password
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your password (min 8 characters)"
required
disabled={isLoading}
/>
</div>
<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"
>
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your first name"
required
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor="lastName"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your last name"
required
disabled={isLoading}
/>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
className="input w-full"
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Username
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Creating Admin Account...
</>
) : (
<>
<UserPlus className="h-4 w-4" />
Create Admin Account
</>
)}
</button>
</form>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Email Address
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your email"
required
disabled={isLoading}
/>
</div>
<div className="mt-8 p-4 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg">
<div className="flex items-start">
<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>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Password
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your password (min 8 characters)"
required
disabled={isLoading}
/>
</div>
export default FirstTimeAdminSetup
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
className="input w-full"
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Creating Admin Account...
</>
) : (
<>
<UserPlus className="h-4 w-4" />
Create Admin Account
</>
)}
</button>
</form>
<div className="mt-8 p-4 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg">
<div className="flex items-start">
<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>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default FirstTimeAdminSetup;

View File

@@ -1,157 +1,159 @@
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,
onSave,
onCancel,
placeholder = "Enter value...",
maxLength = 100,
className = "",
disabled = false,
validate = null,
linkTo = null
const InlineEdit = ({
value,
onSave,
onCancel,
placeholder = "Enter value...",
maxLength = 100,
className = "",
disabled = false,
validate = null,
linkTo = null,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const inputRef = useRef(null);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const inputRef = useRef(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
useEffect(() => {
setEditValue(value);
}, [value]);
useEffect(() => {
setEditValue(value);
}, [value]);
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setEditValue(value);
setError('');
};
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setEditValue(value);
setError("");
};
const handleCancel = () => {
setIsEditing(false);
setEditValue(value);
setError('');
if (onCancel) onCancel();
};
const handleCancel = () => {
setIsEditing(false);
setEditValue(value);
setError("");
if (onCancel) onCancel();
};
const handleSave = async () => {
if (disabled || isLoading) return;
const handleSave = async () => {
if (disabled || isLoading) return;
// Validate if validator function provided
if (validate) {
const validationError = validate(editValue);
if (validationError) {
setError(validationError);
return;
}
}
// Validate if validator function provided
if (validate) {
const validationError = validate(editValue);
if (validationError) {
setError(validationError);
return;
}
}
// Check if value actually changed
if (editValue.trim() === value.trim()) {
setIsEditing(false);
return;
}
// Check if value actually changed
if (editValue.trim() === value.trim()) {
setIsEditing(false);
return;
}
setIsLoading(true);
setError('');
setIsLoading(true);
setError("");
try {
await onSave(editValue.trim());
setIsEditing(false);
} catch (err) {
setError(err.message || 'Failed to save');
} finally {
setIsLoading(false);
}
};
try {
await onSave(editValue.trim());
setIsEditing(false);
} catch (err) {
setError(err.message || "Failed to save");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
};
if (isEditing) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
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' : ''}`}
/>
<button
onClick={handleSave}
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"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
{error && (
<span className="text-xs text-red-600 dark:text-red-400">{error}</span>
)}
</div>
);
}
if (isEditing) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
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" : ""}`}
/>
<button
onClick={handleSave}
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"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
{error && (
<span className="text-xs text-red-600 dark:text-red-400">
{error}
</span>
)}
</div>
);
}
const displayValue = linkTo ? (
<Link
to={linkTo}
className="text-sm font-medium text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 cursor-pointer transition-colors"
title="View details"
>
{value}
</Link>
) : (
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{value}
</span>
);
const displayValue = linkTo ? (
<Link
to={linkTo}
className="text-sm font-medium text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 cursor-pointer transition-colors"
title="View details"
>
{value}
</Link>
) : (
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{value}
</span>
);
return (
<div className={`flex items-center gap-2 group ${className}`}>
{displayValue}
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
return (
<div className={`flex items-center gap-2 group ${className}`}>
{displayValue}
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
};
export default InlineEdit;

View File

@@ -1,257 +1,270 @@
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,
onSave,
onCancel,
options = [],
className = "",
disabled = false,
placeholder = "Select group..."
const InlineGroupEdit = ({
value,
onSave,
onCancel,
options = [],
className = "",
disabled = false,
placeholder = "Select group...",
}) => {
const [isEditing, setIsEditing] = useState(false);
const [selectedValue, setSelectedValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const dropdownRef = useRef(null);
const buttonRef = useRef(null);
const [isEditing, setIsEditing] = useState(false);
const [selectedValue, setSelectedValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const dropdownRef = useRef(null);
const buttonRef = useRef(null);
useEffect(() => {
if (isEditing && dropdownRef.current) {
dropdownRef.current.focus();
}
}, [isEditing]);
useEffect(() => {
if (isEditing && dropdownRef.current) {
dropdownRef.current.focus();
}
}, [isEditing]);
useEffect(() => {
setSelectedValue(value);
// Force re-render when value changes
if (!isEditing) {
setIsOpen(false);
}
}, [value, isEditing]);
useEffect(() => {
setSelectedValue(value);
// Force re-render when value changes
if (!isEditing) {
setIsOpen(false);
}
}, [value, isEditing]);
// Calculate dropdown position
const calculateDropdownPosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: rect.width
});
}
};
// Calculate dropdown position
const calculateDropdownPosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: rect.width,
});
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
if (isOpen) {
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);
};
}
}, [isOpen]);
if (isOpen) {
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);
};
}
}, [isOpen]);
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setSelectedValue(value);
setError('');
// Automatically open dropdown when editing starts
setTimeout(() => {
setIsOpen(true);
}, 0);
};
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setSelectedValue(value);
setError("");
// Automatically open dropdown when editing starts
setTimeout(() => {
setIsOpen(true);
}, 0);
};
const handleCancel = () => {
setIsEditing(false);
setSelectedValue(value);
setError('');
setIsOpen(false);
if (onCancel) onCancel();
};
const handleCancel = () => {
setIsEditing(false);
setSelectedValue(value);
setError("");
setIsOpen(false);
if (onCancel) onCancel();
};
const handleSave = async () => {
if (disabled || isLoading) return;
const handleSave = async () => {
if (disabled || isLoading) return;
// Check if value actually changed
if (selectedValue === value) {
setIsEditing(false);
setIsOpen(false);
return;
}
// Check if value actually changed
if (selectedValue === value) {
setIsEditing(false);
setIsOpen(false);
return;
}
setIsLoading(true);
setError('');
setIsLoading(true);
setError("");
try {
await onSave(selectedValue);
// Update the local value to match the saved value
setSelectedValue(selectedValue);
setIsEditing(false);
setIsOpen(false);
} catch (err) {
setError(err.message || 'Failed to save');
} finally {
setIsLoading(false);
}
};
try {
await onSave(selectedValue);
// Update the local value to match the saved value
setSelectedValue(selectedValue);
setIsEditing(false);
setIsOpen(false);
} catch (err) {
setError(err.message || "Failed to save");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
};
const displayValue = useMemo(() => {
if (!value) {
return 'Ungrouped';
}
const option = options.find(opt => opt.id === value);
return option ? option.name : 'Unknown Group';
}, [value, options]);
const displayValue = useMemo(() => {
if (!value) {
return "Ungrouped";
}
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';
}, [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";
}, [value, options]);
const selectedOption = useMemo(() => {
return options.find(opt => opt.id === value);
}, [value, options]);
const selectedOption = useMemo(() => {
return options.find((opt) => opt.id === value);
}, [value, options]);
if (isEditing) {
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
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' : ''}`}
>
<span className="truncate">
{selectedValue ? options.find(opt => opt.id === selectedValue)?.name || 'Unknown Group' : 'Ungrouped'}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</button>
{isOpen && (
<div
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
minWidth: '200px'
}}
>
<div className="py-1">
<button
type="button"
onClick={() => {
setSelectedValue(null);
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' : ''
}`}
>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
Ungrouped
</span>
</button>
{options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
setSelectedValue(option.id);
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' : ''
}`}
>
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: option.color }}
>
{option.name}
</span>
</button>
))}
</div>
</div>
)}
</div>
<button
onClick={handleSave}
disabled={isLoading}
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"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">{error}</span>
)}
</div>
);
}
if (isEditing) {
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
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" : ""}`}
>
<span className="truncate">
{selectedValue
? options.find((opt) => opt.id === selectedValue)?.name ||
"Unknown Group"
: "Ungrouped"}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</button>
return (
<div className={`flex items-center gap-2 group ${className}`}>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${displayColor}`}
style={value ? { backgroundColor: selectedOption?.color } : {}}
>
{displayValue}
</span>
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit group"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
{isOpen && (
<div
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
minWidth: "200px",
}}
>
<div className="py-1">
<button
type="button"
onClick={() => {
setSelectedValue(null);
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"
: ""
}`}
>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
Ungrouped
</span>
</button>
{options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
setSelectedValue(option.id);
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"
: ""
}`}
>
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: option.color }}
>
{option.name}
</span>
</button>
))}
</div>
</div>
)}
</div>
<button
onClick={handleSave}
disabled={isLoading}
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"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">
{error}
</span>
)}
</div>
);
}
return (
<div className={`flex items-center gap-2 group ${className}`}>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${displayColor}`}
style={value ? { backgroundColor: selectedOption?.color } : {}}
>
{displayValue}
</span>
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit group"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
};
export default InlineGroupEdit;

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,59 @@
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 (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 />
}
if (!isAuthenticated()) {
return <Navigate to="/login" replace />;
}
// Check admin requirement
if (requireAdmin && !isAdmin()) {
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>
</div>
</div>
)
}
// Check admin requirement
if (requireAdmin && !isAdmin()) {
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>
</div>
</div>
);
}
// Check specific permission requirement
if (requirePermission && !hasPermission(requirePermission)) {
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>
</div>
</div>
)
}
// Check specific permission requirement
if (requirePermission && !hasPermission(requirePermission)) {
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>
</div>
</div>
);
}
return children
}
return children;
};
export default ProtectedRoute
export default ProtectedRoute;

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,125 +3,125 @@
@tailwind utilities;
@layer base {
html {
font-family: Inter, ui-sans-serif, system-ui;
}
body {
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
}
html {
font-family: Inter, ui-sans-serif, system-ui;
}
body {
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-150;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500;
}
.btn-success {
@apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
}
.btn-warning {
@apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500;
}
.btn-danger {
@apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
}
.btn-outline {
@apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
}
.card {
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
}
.card-hover {
@apply card hover:shadow-card-hover transition-shadow duration-150;
}
.input {
@apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
}
.label {
@apply block text-sm font-medium text-secondary-700 dark:text-secondary-200;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-primary {
@apply badge bg-primary-100 text-primary-800;
}
.badge-secondary {
@apply badge bg-secondary-100 text-secondary-800;
}
.badge-success {
@apply badge bg-success-100 text-success-800;
}
.badge-warning {
@apply badge bg-warning-100 text-warning-800;
}
.badge-danger {
@apply badge bg-danger-100 text-danger-800;
}
.btn {
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-150;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500;
}
.btn-success {
@apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
}
.btn-warning {
@apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500;
}
.btn-danger {
@apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
}
.btn-outline {
@apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
}
.card {
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
}
.card-hover {
@apply card hover:shadow-card-hover transition-shadow duration-150;
}
.input {
@apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
}
.label {
@apply block text-sm font-medium text-secondary-700 dark:text-secondary-200;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-primary {
@apply badge bg-primary-100 text-primary-800;
}
.badge-secondary {
@apply badge bg-secondary-100 text-secondary-800;
}
.badge-success {
@apply badge bg-success-100 text-success-800;
}
.badge-warning {
@apply badge bg-warning-100 text-warning-800;
}
.badge-danger {
@apply badge bg-danger-100 text-danger-800;
}
}
@layer utilities {
.text-shadow {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.dark .scrollbar-thin {
scrollbar-color: #64748b #475569;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: #f1f5f9;
}
.dark .scrollbar-thin::-webkit-scrollbar-track {
background: #475569;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 3px;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #64748b;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
}
.text-shadow {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.dark .scrollbar-thin {
scrollbar-color: #64748b #475569;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: #f1f5f9;
}
.dark .scrollbar-thin::-webkit-scrollbar-track {
background: #475569;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 3px;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #64748b;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
}

View File

@@ -1,27 +1,27 @@
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({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
})
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
});
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
)
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,498 +1,501 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Plus,
Edit,
Trash2,
Server,
Users,
AlertTriangle,
CheckCircle
} from 'lucide-react'
import { hostGroupsAPI } from '../utils/api'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
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),
})
// Fetch host groups
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)
},
onError: (error) => {
console.error('Failed to create host group:', error)
}
})
// Create host group mutation
const createMutation = useMutation({
mutationFn: (data) => hostGroupsAPI.create(data),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowCreateModal(false);
},
onError: (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)
},
onError: (error) => {
console.error('Failed to update 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);
},
onError: (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)
},
onError: (error) => {
console.error('Failed to delete host group:', error)
}
})
// Delete host group mutation
const deleteMutation = useMutation({
mutationFn: (id) => hostGroupsAPI.delete(id),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowDeleteModal(false);
setGroupToDelete(null);
},
onError: (error) => {
console.error("Failed to delete host group:", error);
},
});
const handleCreate = (data) => {
createMutation.mutate(data)
}
const handleCreate = (data) => {
createMutation.mutate(data);
};
const handleEdit = (group) => {
setSelectedGroup(group)
setShowEditModal(true)
}
const handleEdit = (group) => {
setSelectedGroup(group);
setShowEditModal(true);
};
const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data })
}
const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data });
};
const handleDeleteClick = (group) => {
setGroupToDelete(group)
setShowDeleteModal(true)
}
const handleDeleteClick = (group) => {
setGroupToDelete(group);
setShowDeleteModal(true);
};
const handleDeleteConfirm = () => {
deleteMutation.mutate(groupToDelete.id)
}
const handleDeleteConfirm = () => {
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 (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) {
return (
<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 host groups
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || 'Failed to load host groups'}
</p>
</div>
</div>
</div>
)
}
if (error) {
return (
<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 host groups
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || "Failed to load host groups"}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-secondary-600 dark:text-secondary-300">
Organize your hosts into logical groups for better management
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-secondary-600 dark:text-secondary-300">
Organize your hosts into logical groups for better management
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
{/* Host Groups Grid */}
{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 className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
{group.name}
</h3>
{group.description && (
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
{group.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(group)}
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
title="Edit group"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteClick(group)}
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
title="Delete group"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<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>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
No host groups yet
</h3>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Create your first host group to organize your hosts
</p>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2 mx-auto"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
)}
{/* Host Groups Grid */}
{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 className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
{group.name}
</h3>
{group.description && (
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
{group.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(group)}
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
title="Edit group"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteClick(group)}
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
title="Delete group"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<CreateHostGroupModal
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
)}
<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>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
No host groups yet
</h3>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Create your first host group to organize your hosts
</p>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2 mx-auto"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
)}
{/* Edit Modal */}
{showEditModal && selectedGroup && (
<EditHostGroupModal
group={selectedGroup}
onClose={() => {
setShowEditModal(false)
setSelectedGroup(null)
}}
onSubmit={handleUpdate}
isLoading={updateMutation.isPending}
/>
)}
{/* Create Modal */}
{showCreateModal && (
<CreateHostGroupModal
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && groupToDelete && (
<DeleteHostGroupModal
group={groupToDelete}
onClose={() => {
setShowDeleteModal(false)
setGroupToDelete(null)
}}
onConfirm={handleDeleteConfirm}
isLoading={deleteMutation.isPending}
/>
)}
</div>
)
}
{/* Edit Modal */}
{showEditModal && selectedGroup && (
<EditHostGroupModal
group={selectedGroup}
onClose={() => {
setShowEditModal(false);
setSelectedGroup(null);
}}
onSubmit={handleUpdate}
isLoading={updateMutation.isPending}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && groupToDelete && (
<DeleteHostGroupModal
group={groupToDelete}
onClose={() => {
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'
})
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
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-semibold text-secondary-900 dark:text-white mb-4">
Create Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Name *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Creating...' : 'Create Group'}
</button>
</div>
</form>
</div>
</div>
)
}
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-semibold text-secondary-900 dark:text-white mb-4">
Create Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Name *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button type="submit" className="btn-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'
})
const [formData, setFormData] = useState({
name: group.name,
description: group.description || "",
color: group.color || "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
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-semibold text-secondary-900 dark:text-white mb-4">
Edit Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Name *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Updating...' : 'Update Group'}
</button>
</div>
</form>
</div>
</div>
)
}
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-semibold text-secondary-900 dark:text-white mb-4">
Edit Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Name *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Updating..." : "Update Group"}
</button>
</div>
</form>
</div>
</div>
);
};
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
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 items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Host Group
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
This action cannot be undone
</p>
</div>
</div>
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200">
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' : ''}.
You must move or remove these hosts before deleting the group.
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
onClick={onConfirm}
className="btn-danger"
disabled={isLoading || group._count.hosts > 0}
>
{isLoading ? 'Deleting...' : 'Delete Group'}
</button>
</div>
</div>
</div>
)
}
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 items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Host Group
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
This action cannot be undone
</p>
</div>
</div>
export default HostGroups
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200">
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" : ""}.
You must move or remove these hosts before deleting the group.
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
onClick={onConfirm}
className="btn-danger"
disabled={isLoading || group._count.hosts > 0}
>
{isLoading ? "Deleting..." : "Delete Group"}
</button>
</div>
</div>
</div>
);
};
export default HostGroups;

File diff suppressed because it is too large Load Diff

View File

@@ -1,450 +1,487 @@
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 [formData, setFormData] = useState({
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)
const [isSignupMode, setIsSignupMode] = useState(false);
const [formData, setFormData] = useState({
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);
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')
if (response.ok) {
const data = await response.json()
setSignupEnabled(data.signupEnabled)
}
} catch (error) {
console.error('Failed to check signup status:', error)
// Default to disabled on error for security
setSignupEnabled(false)
}
}
checkSignupEnabled()
}, [])
// Check if signup is enabled
useEffect(() => {
const checkSignupEnabled = async () => {
try {
const response = await fetch("/api/v1/auth/signup-enabled");
if (response.ok) {
const data = await response.json();
setSignupEnabled(data.signupEnabled);
}
} catch (error) {
console.error("Failed to check signup status:", error);
// Default to disabled on error for security
setSignupEnabled(false);
}
};
checkSignupEnabled();
}, []);
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
const response = await authAPI.login(formData.username, formData.password)
try {
const response = await authAPI.login(
formData.username,
formData.password,
);
if (response.data.requiresTfa) {
setRequiresTfa(true)
setTfaUsername(formData.username)
setError('')
} else {
// Regular login successful
const result = await login(formData.username, formData.password)
if (result.success) {
navigate('/')
} else {
setError(result.error || 'Login failed')
}
}
} catch (err) {
setError(err.response?.data?.error || 'Login failed')
} finally {
setIsLoading(false)
}
}
if (response.data.requiresTfa) {
setRequiresTfa(true);
setTfaUsername(formData.username);
setError("");
} else {
// Regular login successful
const result = await login(formData.username, formData.password);
if (result.success) {
navigate("/");
} else {
setError(result.error || "Login failed");
}
}
} catch (err) {
setError(err.response?.data?.error || "Login failed");
} finally {
setIsLoading(false);
}
};
const handleSignupSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
const handleSignupSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
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)
try {
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);
// Redirect to dashboard
navigate('/')
} else {
setError('Signup failed - invalid response')
}
} catch (err) {
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)
} finally {
setIsLoading(false)
}
}
// Redirect to dashboard
navigate("/");
} else {
setError("Signup failed - invalid response");
}
} catch (err) {
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);
} finally {
setIsLoading(false);
}
};
const handleTfaSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
const handleTfaSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token)
try {
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))
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));
// Redirect to dashboard
navigate('/')
} else {
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)
// Clear the token input for security
setTfaData({ token: '' })
} finally {
setIsLoading(false)
}
}
// Redirect to dashboard
navigate("/");
} else {
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);
// Clear the token input for security
setTfaData({ token: "" });
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleTfaInputChange = (e) => {
setTfaData({
...tfaData,
[e.target.name]: e.target.value.replace(/\D/g, '').slice(0, 6)
})
// Clear error when user starts typing
if (error) {
setError('')
}
}
const handleTfaInputChange = (e) => {
setTfaData({
...tfaData,
[e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6),
});
// Clear error when user starts typing
if (error) {
setError("");
}
};
const handleBackToLogin = () => {
setRequiresTfa(false)
setTfaData({ token: '' })
setError('')
}
const handleBackToLogin = () => {
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
}
setIsSignupMode(!isSignupMode)
setFormData({
username: '',
email: '',
password: '',
firstName: '',
lastName: ''
})
setError('')
}
const toggleMode = () => {
// Only allow signup mode if signup is enabled
if (!signupEnabled && !isSignupMode) {
return; // Don't allow switching to signup if disabled
}
setIsSignupMode(!isSignupMode);
setFormData({
username: "",
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">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
<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'}
</h2>
<p className="mt-2 text-center text-sm text-secondary-600">
Monitor and manage your Linux package updates
</p>
</div>
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
<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"}
</h2>
<p className="mt-2 text-center text-sm text-secondary-600">
Monitor and manage your Linux package updates
</p>
</div>
{!requiresTfa ? (
<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>
<div className="mt-1 relative">
<input
id="username"
name="username"
type="text"
required
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"}
/>
<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}
/>
</div>
</div>
</div>
{!requiresTfa ? (
<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>
<div className="mt-1 relative">
<input
id="username"
name="username"
type="text"
required
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"
}
/>
<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} />
</div>
</div>
</div>
{isSignupMode && (
<>
<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">
First Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id="firstName"
name="firstName"
type="text"
required
value={formData.firstName}
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="Enter your first name"
/>
</div>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700">
Last Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id="lastName"
name="lastName"
type="text"
required
value={formData.lastName}
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="Enter your last name"
/>
</div>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary-700">
Email
</label>
<div className="mt-1 relative">
<input
id="email"
name="email"
type="email"
required
value={formData.email}
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="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}
/>
</div>
</div>
</div>
</>
)}
{isSignupMode && (
<>
<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"
>
First Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id="firstName"
name="firstName"
type="text"
required
value={formData.firstName}
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="Enter your first name"
/>
</div>
</div>
<div>
<label
htmlFor="lastName"
className="block text-sm font-medium text-secondary-700"
>
Last Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id="lastName"
name="lastName"
type="text"
required
value={formData.lastName}
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="Enter your last name"
/>
</div>
</div>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-secondary-700"
>
Email
</label>
<div className="mt-1 relative">
<input
id="email"
name="email"
type="email"
required
value={formData.email}
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="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} />
</div>
</div>
</div>
</>
)}
<div>
<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'}
required
value={formData.password}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-10 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="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}
/>
</div>
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
>
{showPassword ? (
<EyeOff size={20} color="#64748b" strokeWidth={2} />
) : (
<Eye size={20} color="#64748b" strokeWidth={2} />
)}
</button>
</div>
</div>
</div>
</div>
<div>
<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"}
required
value={formData.password}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-10 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="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} />
</div>
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
>
{showPassword ? (
<EyeOff size={20} color="#64748b" strokeWidth={2} />
) : (
<Eye size={20} color="#64748b" strokeWidth={2} />
)}
</button>
</div>
</div>
</div>
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
</div>
)}
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
</div>
)}
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{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...'}
</div>
) : (
isSignupMode ? 'Create Account' : 'Sign in'
)}
</button>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{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..."}
</div>
) : isSignupMode ? (
"Create Account"
) : (
"Sign in"
)}
</button>
</div>
{signupEnabled && (
<div className="text-center">
<p className="text-sm text-secondary-600">
{isSignupMode ? 'Already have an account?' : "Don't have an account?"}{' '}
<button
type="button"
onClick={toggleMode}
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
>
{isSignupMode ? 'Sign in' : 'Sign up'}
</button>
</p>
</div>
)}
</form>
) : (
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
<Smartphone size={24} color="#2563eb" strokeWidth={2} />
</div>
<h3 className="mt-4 text-lg font-medium text-secondary-900">
Two-Factor Authentication
</h3>
<p className="mt-2 text-sm text-secondary-600">
Enter the 6-digit code from your authenticator app
</p>
</div>
{signupEnabled && (
<div className="text-center">
<p className="text-sm text-secondary-600">
{isSignupMode
? "Already have an account?"
: "Don't have an account?"}{" "}
<button
type="button"
onClick={toggleMode}
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
>
{isSignupMode ? "Sign in" : "Sign up"}
</button>
</p>
</div>
)}
</form>
) : (
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
<Smartphone size={24} color="#2563eb" strokeWidth={2} />
</div>
<h3 className="mt-4 text-lg font-medium text-secondary-900">
Two-Factor Authentication
</h3>
<p className="mt-2 text-sm text-secondary-600">
Enter the 6-digit code from your authenticator app
</p>
</div>
<div>
<label htmlFor="token" className="block text-sm font-medium text-secondary-700">
Verification Code
</label>
<div className="mt-1">
<input
id="token"
name="token"
type="text"
required
value={tfaData.token}
onChange={handleTfaInputChange}
className="appearance-none rounded-md relative block w-full px-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 text-center text-lg font-mono tracking-widest"
placeholder="000000"
maxLength="6"
/>
</div>
</div>
<div>
<label
htmlFor="token"
className="block text-sm font-medium text-secondary-700"
>
Verification Code
</label>
<div className="mt-1">
<input
id="token"
name="token"
type="text"
required
value={tfaData.token}
onChange={handleTfaInputChange}
className="appearance-none rounded-md relative block w-full px-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 text-center text-lg font-mono tracking-widest"
placeholder="000000"
maxLength="6"
/>
</div>
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
</div>
)}
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
</div>
)}
<div className="space-y-3">
<button
type="submit"
disabled={isLoading || tfaData.token.length !== 6}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Verifying...
</div>
) : (
'Verify Code'
)}
</button>
<div className="space-y-3">
<button
type="submit"
disabled={isLoading || tfaData.token.length !== 6}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Verifying...
</div>
) : (
"Verify Code"
)}
</button>
<button
type="button"
onClick={handleBackToLogin}
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 items-center gap-2"
>
<ArrowLeft size={16} color="#475569" strokeWidth={2} />
Back to Login
</button>
</div>
<button
type="button"
onClick={handleBackToLogin}
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 items-center gap-2"
>
<ArrowLeft size={16} color="#475569" strokeWidth={2} />
Back to Login
</button>
</div>
<div className="text-center">
<p className="text-sm text-secondary-600">
Don't have access to your authenticator? Use a backup code.
</p>
</div>
</form>
)}
</div>
</div>
)
}
<div className="text-center">
<p className="text-sm text-secondary-600">
Don't have access to your authenticator? Use a backup code.
</p>
</div>
</form>
)}
</div>
</div>
);
};
export default Login
export default Login;

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,27 @@
import React from 'react'
import { useParams } from 'react-router-dom'
import { Package } from 'lucide-react'
import { Package } from "lucide-react";
import React from "react";
import { useParams } from "react-router-dom";
const PackageDetail = () => {
const { packageId } = useParams()
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>
<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.
</p>
</div>
</div>
)
}
const { packageId } = useParams();
export default PackageDetail
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>
<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.
</p>
</div>
</div>
);
};
export default PackageDetail;

File diff suppressed because it is too large Load Diff

View File

@@ -1,394 +1,479 @@
import React, { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Shield,
Settings,
Users,
Server,
Package,
BarChart3,
Download,
Eye,
Edit,
Trash2,
Plus,
Save,
X,
AlertTriangle,
RefreshCw
} from 'lucide-react'
import { permissionsAPI } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
BarChart3,
Download,
Edit,
Eye,
Package,
Plus,
RefreshCw,
Save,
Server,
Settings,
Shield,
Trash2,
Users,
X,
} 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)
})
// Fetch all role permissions
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),
onSuccess: () => {
queryClient.invalidateQueries(['rolePermissions'])
setEditingRole(null)
// Refresh user permissions to apply changes immediately
refreshPermissions()
}
})
// Update role permissions mutation
const updateRoleMutation = useMutation({
mutationFn: ({ role, permissions }) =>
permissionsAPI.updateRole(role, permissions),
onSuccess: () => {
queryClient.invalidateQueries(["rolePermissions"]);
setEditingRole(null);
// Refresh user permissions to apply changes immediately
refreshPermissions();
},
});
// Delete role mutation
const deleteRoleMutation = useMutation({
mutationFn: (role) => permissionsAPI.deleteRole(role),
onSuccess: () => {
queryClient.invalidateQueries(['rolePermissions'])
}
})
// Delete role mutation
const deleteRoleMutation = useMutation({
mutationFn: (role) => permissionsAPI.deleteRole(role),
onSuccess: () => {
queryClient.invalidateQueries(["rolePermissions"]);
},
});
const handleSavePermissions = async (role, permissions) => {
try {
await updateRoleMutation.mutateAsync({ role, permissions })
} catch (error) {
console.error('Failed to update permissions:', error)
}
}
const handleSavePermissions = async (role, permissions) => {
try {
await updateRoleMutation.mutateAsync({ role, permissions });
} catch (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.`)) {
try {
await deleteRoleMutation.mutateAsync(role)
} catch (error) {
console.error('Failed to delete role:', error)
}
}
}
const handleDeleteRole = async (role) => {
if (
window.confirm(
`Are you sure you want to delete the "${role}" role? This action cannot be undone.`,
)
) {
try {
await deleteRoleMutation.mutateAsync(role);
} catch (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 (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) {
return (
<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 permissions</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
)
}
if (error) {
return (
<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 permissions
</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-end items-center">
<div className="flex space-x-3">
<button
onClick={() => refreshPermissions()}
className="inline-flex items-center px-4 py-2 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh Permissions
</button>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<Plus className="h-4 w-4 mr-2" />
Add Role
</button>
</div>
</div>
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-end items-center">
<div className="flex space-x-3">
<button
onClick={() => refreshPermissions()}
className="inline-flex items-center px-4 py-2 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh Permissions
</button>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<Plus className="h-4 w-4 mr-2" />
Add Role
</button>
</div>
</div>
{/* Roles List */}
<div className="space-y-4">
{roles && Array.isArray(roles) && roles.map((role) => (
<RolePermissionsCard
key={role.id}
role={role}
isEditing={editingRole === role.role}
onEdit={() => setEditingRole(role.role)}
onCancel={() => setEditingRole(null)}
onSave={handleSavePermissions}
onDelete={handleDeleteRole}
/>
))}
</div>
{/* Roles List */}
<div className="space-y-4">
{roles &&
Array.isArray(roles) &&
roles.map((role) => (
<RolePermissionsCard
key={role.id}
role={role}
isEditing={editingRole === role.role}
onEdit={() => setEditingRole(role.role)}
onCancel={() => setEditingRole(null)}
onSave={handleSavePermissions}
onDelete={handleDeleteRole}
/>
))}
</div>
{/* Add Role Modal */}
<AddRoleModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={() => {
queryClient.invalidateQueries(['rolePermissions'])
setShowAddModal(false)
}}
/>
</div>
)
}
{/* Add Role Modal */}
<AddRoleModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={() => {
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])
// Sync permissions state with role prop when it changes
useEffect(() => {
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' }
]
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",
},
];
const handlePermissionChange = (key, value) => {
setPermissions(prev => ({
...prev,
[key]: value
}))
}
const handlePermissionChange = (key, value) => {
setPermissions((prev) => ({
...prev,
[key]: value,
}));
};
const handleSave = () => {
onSave(role.role, permissions)
}
const handleSave = () => {
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">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<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>
{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
</span>
)}
</div>
<div className="flex items-center space-x-2">
{isEditing ? (
<>
<button
onClick={handleSave}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
>
<Save className="h-4 w-4 mr-1" />
Save
</button>
<button
onClick={onCancel}
className="inline-flex items-center px-3 py-1 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
<X className="h-4 w-4 mr-1" />
Cancel
</button>
</>
) : (
<>
<button
onClick={onEdit}
disabled={isBuiltInRole}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
{!isBuiltInRole && (
<button
onClick={() => onDelete(role.role)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
)}
</>
)}
</div>
</div>
</div>
return (
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<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>
{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
</span>
)}
</div>
<div className="flex items-center space-x-2">
{isEditing ? (
<>
<button
onClick={handleSave}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
>
<Save className="h-4 w-4 mr-1" />
Save
</button>
<button
onClick={onCancel}
className="inline-flex items-center px-3 py-1 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
<X className="h-4 w-4 mr-1" />
Cancel
</button>
</>
) : (
<>
<button
onClick={onEdit}
disabled={isBuiltInRole}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
{!isBuiltInRole && (
<button
onClick={() => onDelete(role.role)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
)}
</>
)}
</div>
</div>
</div>
<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]
return (
<div key={field.key} className="flex items-start">
<div className="flex items-center h-5">
<input
type="checkbox"
checked={isChecked}
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>
<div className="ml-3">
<div className="flex items-center">
<Icon className="h-4 w-4 text-secondary-400 mr-2" />
<label className="text-sm font-medium text-secondary-900 dark:text-white">
{field.label}
</label>
</div>
<p className="text-xs text-secondary-500 mt-1">
{field.description}
</p>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
<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];
return (
<div key={field.key} className="flex items-start">
<div className="flex items-center h-5">
<input
type="checkbox"
checked={isChecked}
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>
<div className="ml-3">
<div className="flex items-center">
<Icon className="h-4 w-4 text-secondary-400 mr-2" />
<label className="text-sm font-medium text-secondary-900 dark:text-white">
{field.label}
</label>
</div>
<p className="text-xs text-secondary-500 mt-1">
{field.description}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
// Add Role Modal Component
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
role: '',
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: false,
can_view_packages: true,
can_manage_packages: false,
can_view_users: false,
can_manage_users: false,
can_view_reports: true,
can_export_data: false,
can_manage_settings: false
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [formData, setFormData] = useState({
role: "",
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: false,
can_view_packages: true,
can_manage_packages: false,
can_view_users: false,
can_manage_users: false,
can_view_reports: true,
can_export_data: false,
can_manage_settings: false,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
await permissionsAPI.updateRole(formData.role, formData)
onSuccess()
} catch (err) {
setError(err.response?.data?.error || 'Failed to create role')
} finally {
setIsLoading(false)
}
}
try {
await permissionsAPI.updateRole(formData.role, formData);
onSuccess();
} catch (err) {
setError(err.response?.data?.error || "Failed to create role");
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value
})
}
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[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>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Role Name
</label>
<input
type="text"
name="role"
required
value={formData.role}
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"
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>
</div>
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>
<div className="space-y-3">
<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' }
].map((permission) => (
<div key={permission.key} className="flex items-center">
<input
type="checkbox"
name={permission.key}
checked={formData[permission.key]}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200">
{permission.label}
</label>
</div>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Role Name
</label>
<input
type="text"
name="role"
required
value={formData.role}
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"
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>
</div>
{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>
</div>
)}
<div className="space-y-3">
<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" },
].map((permission) => (
<div key={permission.key} className="flex items-center">
<input
type="checkbox"
name={permission.key}
checked={formData[permission.key]}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200">
{permission.label}
</label>
</div>
))}
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
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'}
</button>
</div>
</form>
</div>
</div>
)
}
{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>
</div>
)}
export default Permissions
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
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"}
</button>
</div>
</form>
</div>
</div>
);
};
export default Permissions;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,369 +1,432 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
Server,
Shield,
ShieldOff,
AlertTriangle,
Users,
Globe,
Lock,
Unlock,
Database,
Calendar,
Activity
} from 'lucide-react';
import { repositoryAPI } from '../utils/api';
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
ArrowLeft,
Calendar,
Database,
Globe,
Lock,
Server,
Shield,
ShieldOff,
Unlock,
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();
const queryClient = useQueryClient();
const [editMode, setEditMode] = useState(false);
const [formData, setFormData] = useState({});
const { repositoryId } = useParams();
const queryClient = useQueryClient();
const [editMode, setEditMode] = useState(false);
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
});
// Fetch repository details
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']);
setEditMode(false);
}
});
// Update repository mutation
const updateRepositoryMutation = useMutation({
mutationFn: (data) => repositoryAPI.update(repositoryId, data),
onSuccess: () => {
queryClient.invalidateQueries(["repository", repositoryId]);
queryClient.invalidateQueries(["repositories"]);
setEditMode(false);
},
});
const handleEdit = () => {
setFormData({
name: repository.name,
description: repository.description || "",
is_active: repository.is_active,
priority: repository.priority || "",
});
setEditMode(true);
};
const handleEdit = () => {
setFormData({
name: repository.name,
description: repository.description || '',
is_active: repository.is_active,
priority: repository.priority || ''
});
setEditMode(true);
};
const handleSave = () => {
updateRepositoryMutation.mutate(formData);
};
const handleSave = () => {
updateRepositoryMutation.mutate(formData);
};
const handleCancel = () => {
setEditMode(false);
setFormData({});
};
const handleCancel = () => {
setEditMode(false);
setFormData({});
};
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) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Repositories
</Link>
</div>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-red-400 mr-2" />
<span className="text-red-700 dark:text-red-300">
Failed to load repository: {error.message}
</span>
</div>
</div>
</div>
);
}
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 (!repository) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Repositories
</Link>
</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>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
The repository you're looking for doesn't exist.
</p>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Repositories
</Link>
</div>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-red-400 mr-2" />
<span className="text-red-700 dark:text-red-300">
Failed to load repository: {error.message}
</span>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<div>
<div className="flex items-center gap-3">
{repository.isSecure ? (
<Lock className="h-6 w-6 text-green-600" />
) : (
<Unlock className="h-6 w-6 text-orange-600" />
)}
<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 ${
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"}
</span>
</div>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
Repository configuration and host assignments
</p>
</div>
</div>
<div className="flex items-center gap-2">
{editMode ? (
<>
<button
onClick={handleCancel}
className="btn-outline"
disabled={updateRepositoryMutation.isPending}
>
Cancel
</button>
<button
onClick={handleSave}
className="btn-primary"
disabled={updateRepositoryMutation.isPending}
>
{updateRepositoryMutation.isPending
? "Saving..."
: "Save Changes"}
</button>
</>
) : (
<button onClick={handleEdit} className="btn-primary">
Edit Repository
</button>
)}
</div>
</div>
if (!repository) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Repositories
</Link>
</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>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
The repository you're looking for doesn't exist.
</p>
</div>
</div>
);
}
{/* Repository Information */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<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">
Repository Information
</h2>
</div>
<div className="px-6 py-4 space-y-4">
{editMode ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Repository Name
</label>
<input
type="text"
value={formData.name}
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>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Priority
</label>
<input
type="number"
value={formData.priority}
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"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Description
</label>
<textarea
value={formData.description}
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"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
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"
>
Repository is active
</label>
</div>
</div>
) : (
<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>
<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>
</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>
</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>
</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>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Security
</label>
<div className="flex items-center mt-1">
{repository.isSecure ? (
<>
<Shield className="h-4 w-4 text-green-600 mr-2" />
<span className="text-green-600">Secure (HTTPS)</span>
</>
) : (
<>
<ShieldOff className="h-4 w-4 text-orange-600 mr-2" />
<span className="text-orange-600">Insecure (HTTP)</span>
</>
)}
</div>
</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>
</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>
</div>
)}
<div>
<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">
{new Date(repository.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<div>
<div className="flex items-center gap-3">
{repository.isSecure ? (
<Lock className="h-6 w-6 text-green-600" />
) : (
<Unlock className="h-6 w-6 text-orange-600" />
)}
<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 ${
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'}
</span>
</div>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
Repository configuration and host assignments
</p>
</div>
</div>
<div className="flex items-center gap-2">
{editMode ? (
<>
<button
onClick={handleCancel}
className="btn-outline"
disabled={updateRepositoryMutation.isPending}
>
Cancel
</button>
<button
onClick={handleSave}
className="btn-primary"
disabled={updateRepositoryMutation.isPending}
>
{updateRepositoryMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</>
) : (
<button
onClick={handleEdit}
className="btn-primary"
>
Edit Repository
</button>
)}
</div>
</div>
{/* Repository Information */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<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">
Repository Information
</h2>
</div>
<div className="px-6 py-4 space-y-4">
{editMode ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Repository Name
</label>
<input
type="text"
value={formData.name}
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>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Priority
</label>
<input
type="number"
value={formData.priority}
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"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Description
</label>
<textarea
value={formData.description}
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"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
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">
Repository is active
</label>
</div>
</div>
) : (
<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>
<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>
</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>
</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>
</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>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Security</label>
<div className="flex items-center mt-1">
{repository.isSecure ? (
<>
<Shield className="h-4 w-4 text-green-600 mr-2" />
<span className="text-green-600">Secure (HTTPS)</span>
</>
) : (
<>
<ShieldOff className="h-4 w-4 text-orange-600 mr-2" />
<span className="text-orange-600">Insecure (HTTP)</span>
</>
)}
</div>
</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>
</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>
</div>
)}
<div>
<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">
{new Date(repository.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Hosts Using This Repository */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<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})
</h2>
</div>
{!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>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
This repository hasn't been reported by any hosts yet.
</p>
</div>
) : (
<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 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>
<Link
to={`/hosts/${hostRepo.hosts.id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{hostRepo.hosts.friendly_name}
</Link>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
<span>IP: {hostRepo.hosts.ip}</span>
<span>OS: {hostRepo.hosts.os_type} {hostRepo.hosts.os_version}</span>
<span>Last Update: {new Date(hostRepo.hosts.last_update).toLocaleDateString()}</span>
</div>
</div>
</div>
<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-sm text-secondary-900 dark:text-white">
{new Date(hostRepo.last_checked).toLocaleDateString()}
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
{/* Hosts Using This Repository */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<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})
</h2>
</div>
{!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>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
This repository hasn't been reported by any hosts yet.
</p>
</div>
) : (
<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 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>
<Link
to={`/hosts/${hostRepo.hosts.id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{hostRepo.hosts.friendly_name}
</Link>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
<span>IP: {hostRepo.hosts.ip}</span>
<span>
OS: {hostRepo.hosts.os_type}{" "}
{hostRepo.hosts.os_version}
</span>
<span>
Last Update:{" "}
{new Date(
hostRepo.hosts.last_update,
).toLocaleDateString()}
</span>
</div>
</div>
</div>
<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-sm text-secondary-900 dark:text-white">
{new Date(hostRepo.last_checked).toLocaleDateString()}
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default RepositoryDetail;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,231 +1,266 @@
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',
},
})
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor
api.interceptors.request.use(
(config) => {
// Add auth token if available
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
(config) => {
// Add auth token if available
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
// Response interceptor
api.interceptors.response.use(
(response) => response,
(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')
if (currentPath !== '/login' && !isTfaError) {
// Handle unauthorized
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('permissions')
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
(response) => response,
(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");
if (currentPath !== "/login" && !isTfaError) {
// Handle unauthorized
localStorage.removeItem("token");
localStorage.removeItem("user");
localStorage.removeItem("permissions");
window.location.href = "/login";
}
}
return Promise.reject(error);
},
);
// Dashboard API
export const dashboardAPI = {
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')
}
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"),
};
// Admin Hosts API (for management interface)
export const adminHostsAPI = {
create: (data) => api.post('/hosts/create', data),
list: () => api.get('/hosts/admin/list'),
delete: (hostId) => api.delete(`/hosts/${hostId}`),
deleteBulk: (hostIds) => api.delete('/hosts/bulk', { data: { hostIds } }),
regenerateCredentials: (hostId) => api.post(`/hosts/${hostId}/regenerate-credentials`),
updateGroup: (hostId, hostGroupId) => api.put(`/hosts/${hostId}/group`, { hostGroupId }),
bulkUpdateGroup: (hostIds, hostGroupId) => api.put('/hosts/bulk/group', { hostIds, hostGroupId }),
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendly_name: friendlyName })
}
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,
}),
};
// Host Groups API
export const hostGroupsAPI = {
list: () => api.get('/host-groups'),
get: (id) => api.get(`/host-groups/${id}`),
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`),
}
list: () => api.get("/host-groups"),
get: (id) => api.get(`/host-groups/${id}`),
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),
delete: (userId) => api.delete(`/auth/admin/users/${userId}`),
resetPassword: (userId, newPassword) => api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword })
}
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 }),
};
// Permissions API (for role management)
export const permissionsAPI = {
getRoles: () => api.get('/permissions/roles'),
getRole: (role) => api.get(`/permissions/roles/${role}`),
updateRole: (role, permissions) => api.put(`/permissions/roles/${role}`, permissions),
deleteRole: (role) => api.delete(`/permissions/roles/${role}`),
getUserPermissions: () => api.get('/permissions/user-permissions')
}
getRoles: () => api.get("/permissions/roles"),
getRole: (role) => api.get(`/permissions/roles/${role}`),
updateRole: (role, permissions) =>
api.put(`/permissions/roles/${role}`, permissions),
deleteRole: (role) => api.delete(`/permissions/roles/${role}`),
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),
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' })
}
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",
}),
};
// Repository API
export const repositoryAPI = {
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),
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, { isEnabled }),
getStats: () => api.get('/repositories/stats/summary'),
cleanupOrphaned: () => api.delete('/repositories/cleanup/orphaned')
}
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),
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
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),
// Updated to use API credentials
update: (apiId, apiKey, data) => api.post('/hosts/update', data, {
headers: {
'X-API-ID': apiId,
'X-API-KEY': apiKey
}
}),
getInfo: (apiId, apiKey) => api.get('/hosts/info', {
headers: {
'X-API-ID': apiId,
'X-API-KEY': apiKey
}
}),
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 })
}
// Legacy register endpoint (now deprecated)
register: (data) => api.post("/hosts/register", data),
// Updated to use API credentials
update: (apiId, apiKey, data) =>
api.post("/hosts/update", data, {
headers: {
"X-API-ID": apiId,
"X-API-KEY": apiKey,
},
}),
getInfo: (apiId, apiKey) =>
api.get("/hosts/info", {
headers: {
"X-API-ID": apiId,
"X-API-KEY": apiKey,
},
}),
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 }),
};
// Packages API
export const packagesAPI = {
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 }),
update: (packageId, data) => api.put(`/packages/${packageId}`, data),
search: (query, params = {}) => api.get(`/packages/search/${query}`, { 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 }),
update: (packageId, data) => api.put(`/packages/${packageId}`, data),
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
}
if (error.response?.data?.error) {
return error.response.data.error
}
if (error.message) {
return error.message
}
return 'An unexpected error occurred'
}
if (error.response?.data?.message) {
return error.response.data.message;
}
if (error.response?.data?.error) {
return error.response.data.error;
}
if (error.message) {
return error.message;
}
return "An unexpected error occurred";
};
export const formatDate = (date) => {
return new Date(date).toLocaleString()
}
return new Date(date).toLocaleString();
};
// Version API
export const versionAPI = {
getCurrent: () => api.get('/version/current'),
checkUpdates: () => api.get('/version/check-updates'),
testSshKey: (data) => api.post('/version/test-ssh-key', data),
}
getCurrent: () => api.get("/version/current"),
checkUpdates: () => api.get("/version/check-updates"),
testSshKey: (data) => api.post("/version/test-ssh-key", data),
};
// Auth API
export const authAPI = {
login: (username, password) => api.post('/auth/login', { username, password }),
verifyTfa: (username, token) => api.post('/auth/verify-tfa', { username, token }),
signup: (username, email, password, firstName, lastName) => api.post('/auth/signup', { username, email, password, firstName, lastName }),
}
login: (username, password) =>
api.post("/auth/login", { username, password }),
verifyTfa: (username, token) =>
api.post("/auth/verify-tfa", { username, token }),
signup: (username, email, password, firstName, lastName) =>
api.post("/auth/signup", {
username,
email,
password,
firstName,
lastName,
}),
};
// TFA API
export const tfaAPI = {
setup: () => api.get('/tfa/setup'),
verifySetup: (data) => api.post('/tfa/verify-setup', data),
disable: (data) => api.post('/tfa/disable', data),
status: () => api.get('/tfa/status'),
regenerateBackupCodes: () => api.post('/tfa/regenerate-backup-codes'),
verify: (data) => api.post('/tfa/verify', data),
}
setup: () => api.get("/tfa/setup"),
verifySetup: (data) => api.post("/tfa/verify-setup", data),
disable: (data) => api.post("/tfa/disable", data),
status: () => api.get("/tfa/status"),
regenerateBackupCodes: () => api.post("/tfa/regenerate-backup-codes"),
verify: (data) => api.post("/tfa/verify", data),
};
export const formatRelativeTime = (date) => {
const now = new Date()
const diff = now - new Date(date)
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const now = new Date();
const diff = now - new Date(date);
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
return `${seconds} second${seconds > 1 ? 's' : ''} ago`
}
if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
};
export default api
export default api;

View File

@@ -1,65 +1,59 @@
import {
Monitor,
Server,
HardDrive,
Cpu,
Zap,
Shield,
Globe,
Terminal
} from 'lucide-react';
import {
Cpu,
Globe,
HardDrive,
Monitor,
Server,
Shield,
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,
SiLinux,
SiMacos
} from 'react-icons/si';
import {
DiUbuntu,
DiDebian,
DiLinux,
DiWindows
} from 'react-icons/di';
import {
SiAlpinelinux,
SiArchlinux,
SiCentos,
SiDebian,
SiFedora,
SiLinux,
SiMacos,
SiUbuntu,
} from "react-icons/si";
/**
* OS Icon mapping utility
* Maps operating system types to appropriate react-icons components
*/
export const getOSIcon = (osType) => {
if (!osType) return Monitor;
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
// Generic Linux
if (os.includes('linux')) return SiLinux;
// Windows
if (os.includes('windows')) return DiWindows;
// macOS
if (os.includes('mac') || os.includes('darwin')) return SiMacos;
// FreeBSD
if (os.includes('freebsd')) return Server;
// Default fallback
return Monitor;
if (!osType) return Monitor;
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
// Generic Linux
if (os.includes("linux")) return SiLinux;
// Windows
if (os.includes("windows")) return DiWindows;
// macOS
if (os.includes("mac") || os.includes("darwin")) return SiMacos;
// FreeBSD
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';
// react-icons already have the proper brand colors built-in
// This function is kept for compatibility but returns neutral colors
return 'text-gray-600';
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";
};
/**
@@ -79,52 +73,53 @@ export const getOSColor = (osType) => {
* Provides clean, formatted OS names for display
*/
export const getOSDisplayName = (osType) => {
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';
// Generic Linux
if (os.includes('linux')) return 'Linux';
// Windows
if (os.includes('windows')) return 'Windows';
// macOS
if (os.includes('mac') || os.includes('darwin')) return 'macOS';
// FreeBSD
if (os.includes('freebsd')) return 'FreeBSD';
// Return original if no match
return osType;
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";
// Generic Linux
if (os.includes("linux")) return "Linux";
// Windows
if (os.includes("windows")) return "Windows";
// macOS
if (os.includes("mac") || os.includes("darwin")) return "macOS";
// FreeBSD
if (os.includes("freebsd")) return "FreeBSD";
// Return original if no match
return osType;
};
/**
* OS Icon component with proper styling
*/
export const OSIcon = ({ osType, className = "h-4 w-4", showText = false }) => {
const IconComponent = getOSIcon(osType);
const displayName = getOSDisplayName(osType);
if (showText) {
return (
<div className="flex items-center gap-2">
<IconComponent className={className} title={displayName} />
<span className="text-sm">{displayName}</span>
</div>
);
}
return <IconComponent className={className} title={displayName} />;
const IconComponent = getOSIcon(osType);
const displayName = getOSDisplayName(osType);
if (showText) {
return (
<div className="flex items-center gap-2">
<IconComponent className={className} title={displayName} />
<span className="text-sm">{displayName}</span>
</div>
);
}
return <IconComponent className={className} title={displayName} />;
};

View File

@@ -1,85 +1,85 @@
/** @type {import('tailwindcss').Config} */
export default {
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',
},
secondary: {
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',
},
warning: {
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',
},
},
fontFamily: {
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)',
},
},
},
plugins: [],
}
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",
},
secondary: {
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",
},
warning: {
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",
},
},
fontFamily: {
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)",
},
},
},
plugins: [],
};

View File

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