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

View File

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

View File

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

View File

@@ -1,147 +1,185 @@
import React from 'react' import React from "react";
import { Routes, Route } from 'react-router-dom' import { Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from './contexts/AuthContext' import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
import { ThemeProvider } from './contexts/ThemeContext' import Layout from "./components/Layout";
import { UpdateNotificationProvider } from './contexts/UpdateNotificationContext' import ProtectedRoute from "./components/ProtectedRoute";
import ProtectedRoute from './components/ProtectedRoute' import { AuthProvider, useAuth } from "./contexts/AuthContext";
import Layout from './components/Layout' import { ThemeProvider } from "./contexts/ThemeContext";
import Login from './pages/Login' import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
import Dashboard from './pages/Dashboard' import Dashboard from "./pages/Dashboard";
import Hosts from './pages/Hosts' import HostDetail from "./pages/HostDetail";
import Packages from './pages/Packages' import Hosts from "./pages/Hosts";
import Repositories from './pages/Repositories' import Login from "./pages/Login";
import RepositoryDetail from './pages/RepositoryDetail' import Options from "./pages/Options";
import Users from './pages/Users' import PackageDetail from "./pages/PackageDetail";
import Permissions from './pages/Permissions' import Packages from "./pages/Packages";
import Settings from './pages/Settings' import Permissions from "./pages/Permissions";
import Options from './pages/Options' import Profile from "./pages/Profile";
import Profile from './pages/Profile' import Repositories from "./pages/Repositories";
import HostDetail from './pages/HostDetail' import RepositoryDetail from "./pages/RepositoryDetail";
import PackageDetail from './pages/PackageDetail' import Settings from "./pages/Settings";
import FirstTimeAdminSetup from './components/FirstTimeAdminSetup' import Users from "./pages/Users";
function AppRoutes() { function AppRoutes() {
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth() const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth();
const isAuth = isAuthenticated() // Call the function to get boolean value const isAuth = isAuthenticated(); // Call the function to get boolean value
// Show loading while checking if setup is needed // Show loading while checking if setup is needed
if (checkingSetup) { if (checkingSetup) {
return ( 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="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
<p className="text-secondary-600 dark:text-secondary-300">Checking system status...</p> <p className="text-secondary-600 dark:text-secondary-300">
</div> Checking system status...
</div> </p>
) </div>
} </div>
);
}
// Show first-time setup if no admin users exist // Show first-time setup if no admin users exist
if (needsFirstTimeSetup && !isAuth) { if (needsFirstTimeSetup && !isAuth) {
return <FirstTimeAdminSetup /> return <FirstTimeAdminSetup />;
} }
return ( return (
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/" element={ <Route
<ProtectedRoute requirePermission="can_view_dashboard"> path="/"
<Layout> element={
<Dashboard /> <ProtectedRoute requirePermission="can_view_dashboard">
</Layout> <Layout>
</ProtectedRoute> <Dashboard />
} /> </Layout>
<Route path="/hosts" element={ </ProtectedRoute>
<ProtectedRoute requirePermission="can_view_hosts"> }
<Layout> />
<Hosts /> <Route
</Layout> path="/hosts"
</ProtectedRoute> element={
} /> <ProtectedRoute requirePermission="can_view_hosts">
<Route path="/hosts/:hostId" element={ <Layout>
<ProtectedRoute requirePermission="can_view_hosts"> <Hosts />
<Layout> </Layout>
<HostDetail /> </ProtectedRoute>
</Layout> }
</ProtectedRoute> />
} /> <Route
<Route path="/packages" element={ path="/hosts/:hostId"
<ProtectedRoute requirePermission="can_view_packages"> element={
<Layout> <ProtectedRoute requirePermission="can_view_hosts">
<Packages /> <Layout>
</Layout> <HostDetail />
</ProtectedRoute> </Layout>
} /> </ProtectedRoute>
<Route path="/repositories" element={ }
<ProtectedRoute requirePermission="can_view_hosts"> />
<Layout> <Route
<Repositories /> path="/packages"
</Layout> element={
</ProtectedRoute> <ProtectedRoute requirePermission="can_view_packages">
} /> <Layout>
<Route path="/repositories/:repositoryId" element={ <Packages />
<ProtectedRoute requirePermission="can_view_hosts"> </Layout>
<Layout> </ProtectedRoute>
<RepositoryDetail /> }
</Layout> />
</ProtectedRoute> <Route
} /> path="/repositories"
<Route path="/users" element={ element={
<ProtectedRoute requirePermission="can_view_users"> <ProtectedRoute requirePermission="can_view_hosts">
<Layout> <Layout>
<Users /> <Repositories />
</Layout> </Layout>
</ProtectedRoute> </ProtectedRoute>
} /> }
<Route path="/permissions" element={ />
<ProtectedRoute requirePermission="can_manage_settings"> <Route
<Layout> path="/repositories/:repositoryId"
<Permissions /> element={
</Layout> <ProtectedRoute requirePermission="can_view_hosts">
</ProtectedRoute> <Layout>
} /> <RepositoryDetail />
<Route path="/settings" element={ </Layout>
<ProtectedRoute requirePermission="can_manage_settings"> </ProtectedRoute>
<Layout> }
<Settings /> />
</Layout> <Route
</ProtectedRoute> path="/users"
} /> element={
<Route path="/options" element={ <ProtectedRoute requirePermission="can_view_users">
<ProtectedRoute requirePermission="can_manage_hosts"> <Layout>
<Layout> <Users />
<Options /> </Layout>
</Layout> </ProtectedRoute>
</ProtectedRoute> }
} /> />
<Route path="/profile" element={ <Route
<ProtectedRoute> path="/permissions"
<Layout> element={
<Profile /> <ProtectedRoute requirePermission="can_manage_settings">
</Layout> <Layout>
</ProtectedRoute> <Permissions />
} /> </Layout>
<Route path="/packages/:packageId" element={ </ProtectedRoute>
<ProtectedRoute requirePermission="can_view_packages"> }
<Layout> />
<PackageDetail /> <Route
</Layout> path="/settings"
</ProtectedRoute> element={
} /> <ProtectedRoute requirePermission="can_manage_settings">
</Routes> <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() { function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<UpdateNotificationProvider> <UpdateNotificationProvider>
<AppRoutes /> <AppRoutes />
</UpdateNotificationProvider> </UpdateNotificationProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </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 { import {
DndContext, closestCenter,
closestCenter, DndContext,
KeyboardSensor, KeyboardSensor,
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
} from '@dnd-kit/core'; } from "@dnd-kit/core";
import { import {
arrayMove, arrayMove,
SortableContext, SortableContext,
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
verticalListSortingStrategy, useSortable,
} from '@dnd-kit/sortable'; verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
useSortable, Eye,
} from '@dnd-kit/sortable'; EyeOff,
import { CSS } from '@dnd-kit/utilities'; GripVertical,
import { RotateCcw,
X, Save,
GripVertical, Settings as SettingsIcon,
Eye, X,
EyeOff, } from "lucide-react";
Save, import React, { useEffect, useState } from "react";
RotateCcw, import { useTheme } from "../contexts/ThemeContext";
Settings as SettingsIcon import { dashboardPreferencesAPI } from "../utils/api";
} from 'lucide-react';
import { dashboardPreferencesAPI } from '../utils/api';
import { useTheme } from '../contexts/ThemeContext';
// Sortable Card Item Component // Sortable Card Item Component
const SortableCardItem = ({ card, onToggle }) => { const SortableCardItem = ({ card, onToggle }) => {
const { isDark } = useTheme(); const { isDark } = useTheme();
const { const {
attributes, attributes,
listeners, listeners,
setNodeRef, setNodeRef,
transform, transform,
transition, transition,
isDragging, isDragging,
} = useSortable({ id: card.cardId }); } = useSortable({ id: card.cardId });
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
}; };
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${ className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${
isDragging ? 'shadow-lg' : 'shadow-sm' isDragging ? "shadow-lg" : "shadow-sm"
}`} }`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
{...attributes} {...attributes}
{...listeners} {...listeners}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 cursor-grab active:cursor-grabbing" 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" /> <GripVertical className="h-4 w-4" />
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-sm font-medium text-secondary-900 dark:text-white"> <div className="text-sm font-medium text-secondary-900 dark:text-white">
{card.title} {card.title}
{card.typeLabel ? ( {card.typeLabel ? (
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">({card.typeLabel})</span> <span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">
) : null} ({card.typeLabel})
</div> </span>
</div> ) : null}
</div> </div>
</div>
</div>
<button <button
onClick={() => onToggle(card.cardId)} onClick={() => onToggle(card.cardId)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${ className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
card.enabled card.enabled
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800' ? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800"
: 'bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600' : "bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600"
}`} }`}
> >
{card.enabled ? ( {card.enabled ? (
<> <>
<Eye className="h-3 w-3" /> <Eye className="h-3 w-3" />
Visible Visible
</> </>
) : ( ) : (
<> <>
<EyeOff className="h-3 w-3" /> <EyeOff className="h-3 w-3" />
Hidden Hidden
</> </>
)} )}
</button> </button>
</div> </div>
); );
}; };
const DashboardSettingsModal = ({ isOpen, onClose }) => { const DashboardSettingsModal = ({ isOpen, onClose }) => {
const [cards, setCards] = useState([]); const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isDark } = useTheme(); const { isDark } = useTheme();
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}) }),
); );
// Fetch user's dashboard preferences // Fetch user's dashboard preferences
const { data: preferences, isLoading } = useQuery({ const { data: preferences, isLoading } = useQuery({
queryKey: ['dashboardPreferences'], queryKey: ["dashboardPreferences"],
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data), queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
enabled: isOpen enabled: isOpen,
}); });
// Fetch default card configuration // Fetch default card configuration
const { data: defaultCards } = useQuery({ const { data: defaultCards } = useQuery({
queryKey: ['dashboardDefaultCards'], queryKey: ["dashboardDefaultCards"],
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data), queryFn: () =>
enabled: isOpen dashboardPreferencesAPI.getDefaults().then((res) => res.data),
}); enabled: isOpen,
});
// Update preferences mutation // Update preferences mutation
const updatePreferencesMutation = useMutation({ const updatePreferencesMutation = useMutation({
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences), mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
onSuccess: (response) => { onSuccess: (response) => {
// Optimistically update the query cache with the correct data structure // Optimistically update the query cache with the correct data structure
queryClient.setQueryData(['dashboardPreferences'], response.data.preferences); queryClient.setQueryData(
// Also invalidate to ensure fresh data ["dashboardPreferences"],
queryClient.invalidateQueries(['dashboardPreferences']); response.data.preferences,
setHasChanges(false); );
onClose(); // Also invalidate to ensure fresh data
}, queryClient.invalidateQueries(["dashboardPreferences"]);
onError: (error) => { setHasChanges(false);
console.error('Failed to update dashboard preferences:', error); onClose();
} },
}); onError: (error) => {
console.error("Failed to update dashboard preferences:", error);
},
});
// Initialize cards when preferences or defaults are loaded // Initialize cards when preferences or defaults are loaded
useEffect(() => { useEffect(() => {
if (preferences && defaultCards) { if (preferences && defaultCards) {
// Normalize server preferences (snake_case -> camelCase) // Normalize server preferences (snake_case -> camelCase)
const normalizedPreferences = preferences.map((p) => ({ const normalizedPreferences = preferences.map((p) => ({
cardId: p.cardId ?? p.card_id, cardId: p.cardId ?? p.card_id,
enabled: p.enabled, enabled: p.enabled,
order: p.order, order: p.order,
})); }));
const typeLabelFor = (cardId) => { const typeLabelFor = (cardId) => {
if (['totalHosts','hostsNeedingUpdates','totalOutdatedPackages','securityUpdates','upToDateHosts','totalHostGroups','totalUsers','totalRepos'].includes(cardId)) return 'Top card'; if (
if (cardId === 'osDistribution') return 'Pie chart'; [
if (cardId === 'osDistributionBar') return 'Bar chart'; "totalHosts",
if (cardId === 'updateStatus') return 'Pie chart'; "hostsNeedingUpdates",
if (cardId === 'packagePriority') return 'Pie chart'; "totalOutdatedPackages",
if (cardId === 'recentUsers') return 'Table'; "securityUpdates",
if (cardId === 'recentCollection') return 'Table'; "upToDateHosts",
if (cardId === 'quickStats') return 'Wide card'; "totalHostGroups",
return undefined; "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 // Merge user preferences with default cards
const mergedCards = defaultCards const mergedCards = defaultCards
.map((defaultCard) => { .map((defaultCard) => {
const userPreference = normalizedPreferences.find( const userPreference = normalizedPreferences.find(
(p) => p.cardId === defaultCard.cardId (p) => p.cardId === defaultCard.cardId,
); );
return { return {
...defaultCard, ...defaultCard,
enabled: userPreference ? userPreference.enabled : defaultCard.enabled, enabled: userPreference
order: userPreference ? userPreference.order : defaultCard.order, ? userPreference.enabled
typeLabel: typeLabelFor(defaultCard.cardId), : defaultCard.enabled,
}; order: userPreference ? userPreference.order : defaultCard.order,
}) typeLabel: typeLabelFor(defaultCard.cardId),
.sort((a, b) => a.order - b.order); };
})
.sort((a, b) => a.order - b.order);
setCards(mergedCards); setCards(mergedCards);
} }
}, [preferences, defaultCards]); }, [preferences, defaultCards]);
const handleDragEnd = (event) => { const handleDragEnd = (event) => {
const { active, over } = event; const { active, over } = event;
if (active.id !== over.id) { if (active.id !== over.id) {
setCards((items) => { setCards((items) => {
const oldIndex = items.findIndex(item => item.cardId === active.id); const oldIndex = items.findIndex((item) => item.cardId === active.id);
const newIndex = items.findIndex(item => item.cardId === over.id); const newIndex = items.findIndex((item) => item.cardId === over.id);
const newItems = arrayMove(items, oldIndex, newIndex); const newItems = arrayMove(items, oldIndex, newIndex);
// Update order values // Update order values
return newItems.map((item, index) => ({ return newItems.map((item, index) => ({
...item, ...item,
order: index order: index,
})); }));
}); });
setHasChanges(true); setHasChanges(true);
} }
}; };
const handleToggle = (cardId) => { const handleToggle = (cardId) => {
setCards(prevCards => setCards((prevCards) =>
prevCards.map(card => prevCards.map((card) =>
card.cardId === cardId card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
? { ...card, enabled: !card.enabled } ),
: card );
) setHasChanges(true);
); };
setHasChanges(true);
};
const handleSave = () => { const handleSave = () => {
const preferences = cards.map(card => ({ const preferences = cards.map((card) => ({
cardId: card.cardId, cardId: card.cardId,
enabled: card.enabled, enabled: card.enabled,
order: card.order order: card.order,
})); }));
updatePreferencesMutation.mutate(preferences); updatePreferencesMutation.mutate(preferences);
}; };
const handleReset = () => { const handleReset = () => {
if (defaultCards) { if (defaultCards) {
const resetCards = defaultCards.map(card => ({ const resetCards = defaultCards.map((card) => ({
...card, ...card,
enabled: true, enabled: true,
order: card.order order: card.order,
})); }));
setCards(resetCards); setCards(resetCards);
setHasChanges(true); setHasChanges(true);
} }
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} /> <div
className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity"
onClick={onClose}
/>
<div className="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> <div className="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SettingsIcon className="h-5 w-5 text-primary-600" /> <SettingsIcon className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white"> <h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Dashboard Settings Dashboard Settings
</h3> </h3>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300" className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6"> <p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Customize your dashboard by reordering cards and toggling their visibility. Customize your dashboard by reordering cards and toggling their
Drag cards to reorder them, and click the visibility toggle to show/hide cards. visibility. Drag cards to reorder them, and click the visibility
</p> toggle to show/hide cards.
</p>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-8"> <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 className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div> </div>
) : ( ) : (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext items={cards.map(card => card.cardId)} strategy={verticalListSortingStrategy}> <SortableContext
<div className="space-y-2 max-h-96 overflow-y-auto"> items={cards.map((card) => card.cardId)}
{cards.map((card) => ( strategy={verticalListSortingStrategy}
<SortableCardItem >
key={card.cardId} <div className="space-y-2 max-h-96 overflow-y-auto">
card={card} {cards.map((card) => (
onToggle={handleToggle} <SortableCardItem
/> key={card.cardId}
))} card={card}
</div> onToggle={handleToggle}
</SortableContext> />
</DndContext> ))}
)} </div>
</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"> <div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending} disabled={!hasChanges || updatePreferencesMutation.isPending}
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${ className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
!hasChanges || updatePreferencesMutation.isPending !hasChanges || updatePreferencesMutation.isPending
? 'bg-secondary-400 cursor-not-allowed' ? "bg-secondary-400 cursor-not-allowed"
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500' : "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`} }`}
> >
{updatePreferencesMutation.isPending ? ( {updatePreferencesMutation.isPending ? (
<> <>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving... Saving...
</> </>
) : ( ) : (
<> <>
<Save className="h-4 w-4 mr-2" /> <Save className="h-4 w-4 mr-2" />
Save Changes Save Changes
</> </>
)} )}
</button> </button>
<button <button
onClick={handleReset} 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" 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" /> <RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults Reset to Defaults
</button> </button>
<button <button
onClick={onClose} 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" 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 Cancel
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
export default DashboardSettingsModal; export default DashboardSettingsModal;

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,59 @@
import React from 'react' import React from "react";
import { Navigate } from 'react-router-dom' import { Navigate } from "react-router-dom";
import { useAuth } from '../contexts/AuthContext' import { useAuth } from "../contexts/AuthContext";
const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => { const ProtectedRoute = ({
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth() children,
requireAdmin = false,
requirePermission = null,
}) => {
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth();
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div> </div>
) );
} }
if (!isAuthenticated()) { if (!isAuthenticated()) {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />;
} }
// Check admin requirement // Check admin requirement
if (requireAdmin && !isAdmin()) { if (requireAdmin && !isAdmin()) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2> <h2 className="text-xl font-semibold text-secondary-900 mb-2">
<p className="text-secondary-600">You don't have permission to access this page.</p> Access Denied
</div> </h2>
</div> <p className="text-secondary-600">
) You don't have permission to access this page.
} </p>
</div>
</div>
);
}
// Check specific permission requirement // Check specific permission requirement
if (requirePermission && !hasPermission(requirePermission)) { if (requirePermission && !hasPermission(requirePermission)) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2> <h2 className="text-xl font-semibold text-secondary-900 mb-2">
<p className="text-secondary-600">You don't have permission to access this page.</p> Access Denied
</div> </h2>
</div> <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 }) => { const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
if (!show) return null if (!show) return null;
return ( return (
<ArrowUpCircle <ArrowUpCircle
className={`${className} text-red-500 animate-pulse`} className={`${className} text-red-500 animate-pulse`}
title="Update available" title="Update available"
/> />
) );
} };
export default UpgradeNotificationIcon export default UpgradeNotificationIcon;

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,450 +1,487 @@
import React, { useState, useEffect } from 'react' import {
import { useNavigate } from 'react-router-dom' AlertCircle,
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft, Mail } from 'lucide-react' ArrowLeft,
import { useAuth } from '../contexts/AuthContext' Eye,
import { authAPI } from '../utils/api' EyeOff,
Lock,
Mail,
Smartphone,
User,
} from "lucide-react";
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { authAPI } from "../utils/api";
const Login = () => { const Login = () => {
const [isSignupMode, setIsSignupMode] = useState(false) const [isSignupMode, setIsSignupMode] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: '', username: "",
email: '', email: "",
password: '', password: "",
firstName: '', firstName: "",
lastName: '' lastName: "",
}) });
const [tfaData, setTfaData] = useState({ const [tfaData, setTfaData] = useState({
token: '' token: "",
}) });
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [requiresTfa, setRequiresTfa] = useState(false) const [requiresTfa, setRequiresTfa] = useState(false);
const [tfaUsername, setTfaUsername] = useState('') const [tfaUsername, setTfaUsername] = useState("");
const [signupEnabled, setSignupEnabled] = useState(false) const [signupEnabled, setSignupEnabled] = useState(false);
const navigate = useNavigate() const navigate = useNavigate();
const { login, setAuthState } = useAuth() const { login, setAuthState } = useAuth();
// Check if signup is enabled // Check if signup is enabled
useEffect(() => { useEffect(() => {
const checkSignupEnabled = async () => { const checkSignupEnabled = async () => {
try { try {
const response = await fetch('/api/v1/auth/signup-enabled') const response = await fetch("/api/v1/auth/signup-enabled");
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json();
setSignupEnabled(data.signupEnabled) setSignupEnabled(data.signupEnabled);
} }
} catch (error) { } catch (error) {
console.error('Failed to check signup status:', error) console.error("Failed to check signup status:", error);
// Default to disabled on error for security // Default to disabled on error for security
setSignupEnabled(false) setSignupEnabled(false);
} }
} };
checkSignupEnabled() checkSignupEnabled();
}, []) }, []);
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError("");
try { try {
const response = await authAPI.login(formData.username, formData.password) const response = await authAPI.login(
formData.username,
formData.password,
);
if (response.data.requiresTfa) { if (response.data.requiresTfa) {
setRequiresTfa(true) setRequiresTfa(true);
setTfaUsername(formData.username) setTfaUsername(formData.username);
setError('') setError("");
} else { } else {
// Regular login successful // Regular login successful
const result = await login(formData.username, formData.password) const result = await login(formData.username, formData.password);
if (result.success) { if (result.success) {
navigate('/') navigate("/");
} else { } else {
setError(result.error || 'Login failed') setError(result.error || "Login failed");
} }
} }
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Login failed') setError(err.response?.data?.error || "Login failed");
} finally { } finally {
setIsLoading(false) setIsLoading(false);
} }
} };
const handleSignupSubmit = async (e) => { const handleSignupSubmit = async (e) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError("");
try { try {
const response = await authAPI.signup(formData.username, formData.email, formData.password, formData.firstName, formData.lastName) const response = await authAPI.signup(
if (response.data && response.data.token) { formData.username,
// Update AuthContext state and localStorage formData.email,
setAuthState(response.data.token, response.data.user) 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 // Redirect to dashboard
navigate('/') navigate("/");
} else { } else {
setError('Signup failed - invalid response') setError("Signup failed - invalid response");
} }
} catch (err) { } catch (err) {
console.error('Signup error:', err) console.error("Signup error:", err);
const errorMessage = err.response?.data?.error || const errorMessage =
(err.response?.data?.errors && err.response.data.errors.length > 0 err.response?.data?.error ||
? err.response.data.errors.map(e => e.msg).join(', ') (err.response?.data?.errors && err.response.data.errors.length > 0
: err.message || 'Signup failed') ? err.response.data.errors.map((e) => e.msg).join(", ")
setError(errorMessage) : err.message || "Signup failed");
} finally { setError(errorMessage);
setIsLoading(false) } finally {
} setIsLoading(false);
} }
};
const handleTfaSubmit = async (e) => { const handleTfaSubmit = async (e) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError("");
try { try {
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token) const response = await authAPI.verifyTfa(tfaUsername, tfaData.token);
if (response.data && response.data.token) { if (response.data && response.data.token) {
// Store token and user data // Store token and user data
localStorage.setItem('token', response.data.token) localStorage.setItem("token", response.data.token);
localStorage.setItem('user', JSON.stringify(response.data.user)) localStorage.setItem("user", JSON.stringify(response.data.user));
// Redirect to dashboard // Redirect to dashboard
navigate('/') navigate("/");
} else { } else {
setError('TFA verification failed - invalid response') setError("TFA verification failed - invalid response");
} }
} catch (err) { } catch (err) {
console.error('TFA verification error:', err) console.error("TFA verification error:", err);
const errorMessage = err.response?.data?.error || err.message || 'TFA verification failed' const errorMessage =
setError(errorMessage) err.response?.data?.error || err.message || "TFA verification failed";
// Clear the token input for security setError(errorMessage);
setTfaData({ token: '' }) // Clear the token input for security
} finally { setTfaData({ token: "" });
setIsLoading(false) } finally {
} setIsLoading(false);
} }
};
const handleInputChange = (e) => { const handleInputChange = (e) => {
setFormData({ setFormData({
...formData, ...formData,
[e.target.name]: e.target.value [e.target.name]: e.target.value,
}) });
} };
const handleTfaInputChange = (e) => { const handleTfaInputChange = (e) => {
setTfaData({ setTfaData({
...tfaData, ...tfaData,
[e.target.name]: e.target.value.replace(/\D/g, '').slice(0, 6) [e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6),
}) });
// Clear error when user starts typing // Clear error when user starts typing
if (error) { if (error) {
setError('') setError("");
} }
} };
const handleBackToLogin = () => { const handleBackToLogin = () => {
setRequiresTfa(false) setRequiresTfa(false);
setTfaData({ token: '' }) setTfaData({ token: "" });
setError('') setError("");
} };
const toggleMode = () => { const toggleMode = () => {
// Only allow signup mode if signup is enabled // Only allow signup mode if signup is enabled
if (!signupEnabled && !isSignupMode) { if (!signupEnabled && !isSignupMode) {
return // Don't allow switching to signup if disabled return; // Don't allow switching to signup if disabled
} }
setIsSignupMode(!isSignupMode) setIsSignupMode(!isSignupMode);
setFormData({ setFormData({
username: '', username: "",
email: '', email: "",
password: '', password: "",
firstName: '', firstName: "",
lastName: '' lastName: "",
}) });
setError('') setError("");
} };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8"> <div className="max-w-md w-full space-y-8">
<div> <div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100"> <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} /> <Lock size={24} color="#2563eb" strokeWidth={2} />
</div> </div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900"> <h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
{isSignupMode ? 'Create PatchMon Account' : 'Sign in to PatchMon'} {isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"}
</h2> </h2>
<p className="mt-2 text-center text-sm text-secondary-600"> <p className="mt-2 text-center text-sm text-secondary-600">
Monitor and manage your Linux package updates Monitor and manage your Linux package updates
</p> </p>
</div> </div>
{!requiresTfa ? ( {!requiresTfa ? (
<form className="mt-8 space-y-6" onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}> <form
<div className="space-y-4"> className="mt-8 space-y-6"
<div> onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}
<label htmlFor="username" className="block text-sm font-medium text-secondary-700"> >
{isSignupMode ? 'Username' : 'Username or Email'} <div className="space-y-4">
</label> <div>
<div className="mt-1 relative"> <label
<input htmlFor="username"
id="username" className="block text-sm font-medium text-secondary-700"
name="username" >
type="text" {isSignupMode ? "Username" : "Username or Email"}
required </label>
value={formData.username} <div className="mt-1 relative">
onChange={handleInputChange} <input
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" id="username"
placeholder={isSignupMode ? "Enter your username" : "Enter your username or email"} name="username"
/> type="text"
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center"> required
<User value={formData.username}
size={20} onChange={handleInputChange}
color="#64748b" 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"
strokeWidth={2} placeholder={
/> isSignupMode
</div> ? "Enter your username"
</div> : "Enter your username or email"
</div> }
/>
<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 && ( {isSignupMode && (
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label htmlFor="firstName" className="block text-sm font-medium text-secondary-700"> <label
First Name htmlFor="firstName"
</label> className="block text-sm font-medium text-secondary-700"
<div className="mt-1 relative"> >
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> First Name
<User className="h-5 w-5 text-secondary-400" /> </label>
</div> <div className="mt-1 relative">
<input <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
id="firstName" <User className="h-5 w-5 text-secondary-400" />
name="firstName" </div>
type="text" <input
required id="firstName"
value={formData.firstName} name="firstName"
onChange={handleInputChange} type="text"
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" required
placeholder="Enter your first name" value={formData.firstName}
/> onChange={handleInputChange}
</div> 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"
</div> placeholder="Enter your first name"
<div> />
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700"> </div>
Last Name </div>
</label> <div>
<div className="mt-1 relative"> <label
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> htmlFor="lastName"
<User className="h-5 w-5 text-secondary-400" /> className="block text-sm font-medium text-secondary-700"
</div> >
<input Last Name
id="lastName" </label>
name="lastName" <div className="mt-1 relative">
type="text" <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
required <User className="h-5 w-5 text-secondary-400" />
value={formData.lastName} </div>
onChange={handleInputChange} <input
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" id="lastName"
placeholder="Enter your last name" name="lastName"
/> type="text"
</div> required
</div> value={formData.lastName}
</div> onChange={handleInputChange}
<div> 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"
<label htmlFor="email" className="block text-sm font-medium text-secondary-700"> placeholder="Enter your last name"
Email />
</label> </div>
<div className="mt-1 relative"> </div>
<input </div>
id="email" <div>
name="email" <label
type="email" htmlFor="email"
required className="block text-sm font-medium text-secondary-700"
value={formData.email} >
onChange={handleInputChange} Email
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" </label>
placeholder="Enter your email" <div className="mt-1 relative">
/> <input
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center"> id="email"
<Mail name="email"
size={20} type="email"
color="#64748b" required
strokeWidth={2} value={formData.email}
/> onChange={handleInputChange}
</div> 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"
</div> placeholder="Enter your email"
</div> />
</> <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> <div>
<label htmlFor="password" className="block text-sm font-medium text-secondary-700"> <label
Password htmlFor="password"
</label> className="block text-sm font-medium text-secondary-700"
<div className="mt-1 relative"> >
<input Password
id="password" </label>
name="password" <div className="mt-1 relative">
type={showPassword ? 'text' : 'password'} <input
required id="password"
value={formData.password} name="password"
onChange={handleInputChange} type={showPassword ? "text" : "password"}
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" required
placeholder="Enter your password" value={formData.password}
/> onChange={handleInputChange}
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center"> 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"
<Lock placeholder="Enter your password"
size={20} />
color="#64748b" <div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
strokeWidth={2} <Lock size={20} color="#64748b" strokeWidth={2} />
/> </div>
</div> <div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center"> <button
<button type="button"
type="button" onClick={() => setShowPassword(!showPassword)}
onClick={() => setShowPassword(!showPassword)} className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center" >
> {showPassword ? (
{showPassword ? ( <EyeOff size={20} color="#64748b" strokeWidth={2} />
<EyeOff size={20} color="#64748b" strokeWidth={2} /> ) : (
) : ( <Eye size={20} color="#64748b" strokeWidth={2} />
<Eye size={20} color="#64748b" strokeWidth={2} /> )}
)} </button>
</button> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
{error && ( {error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3"> <div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex"> <div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} /> <AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3"> <div className="ml-3">
<p className="text-sm text-danger-700">{error}</p> <p className="text-sm text-danger-700">{error}</p>
</div> </div>
</div> </div>
</div> </div>
)} )}
<div> <div>
<button <button
type="submit" type="submit"
disabled={isLoading} 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" 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 ? ( {isLoading ? (
<div className="flex items-center"> <div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
{isSignupMode ? 'Creating account...' : 'Signing in...'} {isSignupMode ? "Creating account..." : "Signing in..."}
</div> </div>
) : ( ) : isSignupMode ? (
isSignupMode ? 'Create Account' : 'Sign in' "Create Account"
)} ) : (
</button> "Sign in"
</div> )}
</button>
</div>
{signupEnabled && ( {signupEnabled && (
<div className="text-center"> <div className="text-center">
<p className="text-sm text-secondary-600"> <p className="text-sm text-secondary-600">
{isSignupMode ? 'Already have an account?' : "Don't have an account?"}{' '} {isSignupMode
<button ? "Already have an account?"
type="button" : "Don't have an account?"}{" "}
onClick={toggleMode} <button
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline" type="button"
> onClick={toggleMode}
{isSignupMode ? 'Sign in' : 'Sign up'} className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
</button> >
</p> {isSignupMode ? "Sign in" : "Sign up"}
</div> </button>
)} </p>
</form> </div>
) : ( )}
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}> </form>
<div className="text-center"> ) : (
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100"> <form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
<Smartphone size={24} color="#2563eb" strokeWidth={2} /> <div className="text-center">
</div> <div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
<h3 className="mt-4 text-lg font-medium text-secondary-900"> <Smartphone size={24} color="#2563eb" strokeWidth={2} />
Two-Factor Authentication </div>
</h3> <h3 className="mt-4 text-lg font-medium text-secondary-900">
<p className="mt-2 text-sm text-secondary-600"> Two-Factor Authentication
Enter the 6-digit code from your authenticator app </h3>
</p> <p className="mt-2 text-sm text-secondary-600">
</div> Enter the 6-digit code from your authenticator app
</p>
</div>
<div> <div>
<label htmlFor="token" className="block text-sm font-medium text-secondary-700"> <label
Verification Code htmlFor="token"
</label> className="block text-sm font-medium text-secondary-700"
<div className="mt-1"> >
<input Verification Code
id="token" </label>
name="token" <div className="mt-1">
type="text" <input
required id="token"
value={tfaData.token} name="token"
onChange={handleTfaInputChange} type="text"
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" required
placeholder="000000" value={tfaData.token}
maxLength="6" 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"
</div> placeholder="000000"
</div> maxLength="6"
/>
</div>
</div>
{error && ( {error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3"> <div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex"> <div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} /> <AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3"> <div className="ml-3">
<p className="text-sm text-danger-700">{error}</p> <p className="text-sm text-danger-700">{error}</p>
</div> </div>
</div> </div>
</div> </div>
)} )}
<div className="space-y-3"> <div className="space-y-3">
<button <button
type="submit" type="submit"
disabled={isLoading || tfaData.token.length !== 6} 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" 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 ? ( {isLoading ? (
<div className="flex items-center"> <div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Verifying... Verifying...
</div> </div>
) : ( ) : (
'Verify Code' "Verify Code"
)} )}
</button> </button>
<button <button
type="button" type="button"
onClick={handleBackToLogin} 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" 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} /> <ArrowLeft size={16} color="#475569" strokeWidth={2} />
Back to Login Back to Login
</button> </button>
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-sm text-secondary-600"> <p className="text-sm text-secondary-600">
Don't have access to your authenticator? Use a backup code. Don't have access to your authenticator? Use a backup code.
</p> </p>
</div> </div>
</form> </form>
)} )}
</div> </div>
</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 { Package } from "lucide-react";
import { useParams } from 'react-router-dom' import React from "react";
import { Package } from 'lucide-react' import { useParams } from "react-router-dom";
const PackageDetail = () => { const PackageDetail = () => {
const { packageId } = useParams() const { packageId } = useParams();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="card p-8 text-center">
<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>
);
};
<div className="card p-8 text-center"> export default PackageDetail;
<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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { import {
Shield, AlertTriangle,
Settings, BarChart3,
Users, Download,
Server, Edit,
Package, Eye,
BarChart3, Package,
Download, Plus,
Eye, RefreshCw,
Edit, Save,
Trash2, Server,
Plus, Settings,
Save, Shield,
X, Trash2,
AlertTriangle, Users,
RefreshCw X,
} from 'lucide-react' } from "lucide-react";
import { permissionsAPI } from '../utils/api' import React, { useEffect, useState } from "react";
import { useAuth } from '../contexts/AuthContext' import { useAuth } from "../contexts/AuthContext";
import { permissionsAPI } from "../utils/api";
const Permissions = () => { const Permissions = () => {
const [editingRole, setEditingRole] = useState(null) const [editingRole, setEditingRole] = useState(null);
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient() const queryClient = useQueryClient();
const { refreshPermissions } = useAuth() const { refreshPermissions } = useAuth();
// Fetch all role permissions // Fetch all role permissions
const { data: roles, isLoading, error } = useQuery({ const {
queryKey: ['rolePermissions'], data: roles,
queryFn: () => permissionsAPI.getRoles().then(res => res.data) isLoading,
}) error,
} = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update role permissions mutation // Update role permissions mutation
const updateRoleMutation = useMutation({ const updateRoleMutation = useMutation({
mutationFn: ({ role, permissions }) => permissionsAPI.updateRole(role, permissions), mutationFn: ({ role, permissions }) =>
onSuccess: () => { permissionsAPI.updateRole(role, permissions),
queryClient.invalidateQueries(['rolePermissions']) onSuccess: () => {
setEditingRole(null) queryClient.invalidateQueries(["rolePermissions"]);
// Refresh user permissions to apply changes immediately setEditingRole(null);
refreshPermissions() // Refresh user permissions to apply changes immediately
} refreshPermissions();
}) },
});
// Delete role mutation // Delete role mutation
const deleteRoleMutation = useMutation({ const deleteRoleMutation = useMutation({
mutationFn: (role) => permissionsAPI.deleteRole(role), mutationFn: (role) => permissionsAPI.deleteRole(role),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(['rolePermissions']) queryClient.invalidateQueries(["rolePermissions"]);
} },
}) });
const handleSavePermissions = async (role, permissions) => { const handleSavePermissions = async (role, permissions) => {
try { try {
await updateRoleMutation.mutateAsync({ role, permissions }) await updateRoleMutation.mutateAsync({ role, permissions });
} catch (error) { } catch (error) {
console.error('Failed to update permissions:', error) console.error("Failed to update permissions:", error);
} }
} };
const handleDeleteRole = async (role) => { const handleDeleteRole = async (role) => {
if (window.confirm(`Are you sure you want to delete the "${role}" role? This action cannot be undone.`)) { if (
try { window.confirm(
await deleteRoleMutation.mutateAsync(role) `Are you sure you want to delete the "${role}" role? This action cannot be undone.`,
} catch (error) { )
console.error('Failed to delete role:', error) ) {
} try {
} await deleteRoleMutation.mutateAsync(role);
} } catch (error) {
console.error("Failed to delete role:", error);
}
}
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div> </div>
) );
} }
if (error) { if (error) {
return ( return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4"> <div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex"> <div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" /> <AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3"> <div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">Error loading permissions</h3> <h3 className="text-sm font-medium text-danger-800">
<p className="mt-1 text-sm text-danger-700">{error.message}</p> Error loading permissions
</div> </h3>
</div> <p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div> </div>
) </div>
} </div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex justify-end items-center"> <div className="flex justify-end items-center">
<div className="flex space-x-3"> <div className="flex space-x-3">
<button <button
onClick={() => refreshPermissions()} 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" 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" /> <RefreshCw className="h-4 w-4 mr-2" />
Refresh Permissions Refresh Permissions
</button> </button>
<button <button
onClick={() => setShowAddModal(true)} 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" 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" /> <Plus className="h-4 w-4 mr-2" />
Add Role Add Role
</button> </button>
</div> </div>
</div> </div>
{/* Roles List */} {/* Roles List */}
<div className="space-y-4"> <div className="space-y-4">
{roles && Array.isArray(roles) && roles.map((role) => ( {roles &&
<RolePermissionsCard Array.isArray(roles) &&
key={role.id} roles.map((role) => (
role={role} <RolePermissionsCard
isEditing={editingRole === role.role} key={role.id}
onEdit={() => setEditingRole(role.role)} role={role}
onCancel={() => setEditingRole(null)} isEditing={editingRole === role.role}
onSave={handleSavePermissions} onEdit={() => setEditingRole(role.role)}
onDelete={handleDeleteRole} onCancel={() => setEditingRole(null)}
/> onSave={handleSavePermissions}
))} onDelete={handleDeleteRole}
</div> />
))}
</div>
{/* Add Role Modal */} {/* Add Role Modal */}
<AddRoleModal <AddRoleModal
isOpen={showAddModal} isOpen={showAddModal}
onClose={() => setShowAddModal(false)} onClose={() => setShowAddModal(false)}
onSuccess={() => { onSuccess={() => {
queryClient.invalidateQueries(['rolePermissions']) queryClient.invalidateQueries(["rolePermissions"]);
setShowAddModal(false) setShowAddModal(false);
}} }}
/> />
</div> </div>
) );
} };
// Role Permissions Card Component // Role Permissions Card Component
const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => { const RolePermissionsCard = ({
const [permissions, setPermissions] = useState(role) role,
isEditing,
onEdit,
onCancel,
onSave,
onDelete,
}) => {
const [permissions, setPermissions] = useState(role);
// Sync permissions state with role prop when it changes // Sync permissions state with role prop when it changes
useEffect(() => { useEffect(() => {
setPermissions(role) setPermissions(role);
}, [role]) }, [role]);
const permissionFields = [ const permissionFields = [
{ key: 'can_view_dashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' }, {
{ key: 'can_view_hosts', label: 'View Hosts', icon: Server, description: 'See host information and status' }, key: "can_view_dashboard",
{ key: 'can_manage_hosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' }, label: "View Dashboard",
{ key: 'can_view_packages', label: 'View Packages', icon: Package, description: 'See package information' }, icon: BarChart3,
{ key: 'can_manage_packages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' }, description: "Access to the main dashboard",
{ key: 'can_view_users', label: 'View Users', icon: Users, description: 'See user list and details' }, },
{ key: 'can_manage_users', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' }, {
{ key: 'can_view_reports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' }, key: "can_view_hosts",
{ key: 'can_export_data', label: 'Export Data', icon: Download, description: 'Download data and reports' }, label: "View Hosts",
{ key: 'can_manage_settings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' } icon: Server,
] description: "See host information and status",
},
{
key: "can_manage_hosts",
label: "Manage Hosts",
icon: Edit,
description: "Add, edit, and delete hosts",
},
{
key: "can_view_packages",
label: "View Packages",
icon: Package,
description: "See package information",
},
{
key: "can_manage_packages",
label: "Manage Packages",
icon: Settings,
description: "Edit package details",
},
{
key: "can_view_users",
label: "View Users",
icon: Users,
description: "See user list and details",
},
{
key: "can_manage_users",
label: "Manage Users",
icon: Shield,
description: "Add, edit, and delete users",
},
{
key: "can_view_reports",
label: "View Reports",
icon: BarChart3,
description: "Access to reports and analytics",
},
{
key: "can_export_data",
label: "Export Data",
icon: Download,
description: "Download data and reports",
},
{
key: "can_manage_settings",
label: "Manage Settings",
icon: Settings,
description: "System configuration access",
},
];
const handlePermissionChange = (key, value) => { const handlePermissionChange = (key, value) => {
setPermissions(prev => ({ setPermissions((prev) => ({
...prev, ...prev,
[key]: value [key]: value,
})) }));
} };
const handleSave = () => { const handleSave = () => {
onSave(role.role, permissions) onSave(role.role, permissions);
} };
const isBuiltInRole = role.role === 'admin' || role.role === 'user' const isBuiltInRole = role.role === "admin" || role.role === "user";
return ( return (
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg"> <div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600"> <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 justify-between">
<div className="flex items-center"> <div className="flex items-center">
<Shield className="h-5 w-5 text-primary-600 mr-3" /> <Shield className="h-5 w-5 text-primary-600 mr-3" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">{role.role}</h3> <h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">
{isBuiltInRole && ( {role.role}
<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"> </h3>
Built-in Role {isBuiltInRole && (
</span> <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
</div> </span>
<div className="flex items-center space-x-2"> )}
{isEditing ? ( </div>
<> <div className="flex items-center space-x-2">
<button {isEditing ? (
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" <button
> onClick={handleSave}
<Save className="h-4 w-4 mr-1" /> 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 >
</button> <Save className="h-4 w-4 mr-1" />
<button Save
onClick={onCancel} </button>
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" <button
> onClick={onCancel}
<X className="h-4 w-4 mr-1" /> 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"
Cancel >
</button> <X className="h-4 w-4 mr-1" />
</> Cancel
) : ( </button>
<> </>
<button ) : (
onClick={onEdit} <>
disabled={isBuiltInRole} <button
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" onClick={onEdit}
> disabled={isBuiltInRole}
<Edit className="h-4 w-4 mr-1" /> 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 >
</button> <Edit className="h-4 w-4 mr-1" />
{!isBuiltInRole && ( Edit
<button </button>
onClick={() => onDelete(role.role)} {!isBuiltInRole && (
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" <button
> onClick={() => onDelete(role.role)}
<Trash2 className="h-4 w-4 mr-1" /> 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"
Delete >
</button> <Trash2 className="h-4 w-4 mr-1" />
)} Delete
</> </button>
)} )}
</div> </>
</div> )}
</div> </div>
</div>
</div>
<div className="px-6 py-4"> <div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{permissionFields.map((field) => { {permissionFields.map((field) => {
const Icon = field.icon const Icon = field.icon;
const isChecked = permissions[field.key] const isChecked = permissions[field.key];
return ( return (
<div key={field.key} className="flex items-start"> <div key={field.key} className="flex items-start">
<div className="flex items-center h-5"> <div className="flex items-center h-5">
<input <input
type="checkbox" type="checkbox"
checked={isChecked} checked={isChecked}
onChange={(e) => handlePermissionChange(field.key, e.target.checked)} onChange={(e) =>
disabled={!isEditing || (isBuiltInRole && field.key === 'can_manage_users')} handlePermissionChange(field.key, e.target.checked)
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50" }
/> disabled={
</div> !isEditing ||
<div className="ml-3"> (isBuiltInRole && field.key === "can_manage_users")
<div className="flex items-center"> }
<Icon className="h-4 w-4 text-secondary-400 mr-2" /> className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
<label className="text-sm font-medium text-secondary-900 dark:text-white"> />
{field.label} </div>
</label> <div className="ml-3">
</div> <div className="flex items-center">
<p className="text-xs text-secondary-500 mt-1"> <Icon className="h-4 w-4 text-secondary-400 mr-2" />
{field.description} <label className="text-sm font-medium text-secondary-900 dark:text-white">
</p> {field.label}
</div> </label>
</div> </div>
) <p className="text-xs text-secondary-500 mt-1">
})} {field.description}
</div> </p>
</div> </div>
</div> </div>
) );
} })}
</div>
</div>
</div>
);
};
// Add Role Modal Component // Add Role Modal Component
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => { const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
role: '', role: "",
can_view_dashboard: true, can_view_dashboard: true,
can_view_hosts: true, can_view_hosts: true,
can_manage_hosts: false, can_manage_hosts: false,
can_view_packages: true, can_view_packages: true,
can_manage_packages: false, can_manage_packages: false,
can_view_users: false, can_view_users: false,
can_manage_users: false, can_manage_users: false,
can_view_reports: true, can_view_reports: true,
can_export_data: false, can_export_data: false,
can_manage_settings: false can_manage_settings: false,
}) });
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError("");
try { try {
await permissionsAPI.updateRole(formData.role, formData) await permissionsAPI.updateRole(formData.role, formData);
onSuccess() onSuccess();
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to create role') setError(err.response?.data?.error || "Failed to create role");
} finally { } finally {
setIsLoading(false) setIsLoading(false);
} }
} };
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value, type, checked } = e.target const { name, value, type, checked } = e.target;
setFormData({ setFormData({
...formData, ...formData,
[name]: type === 'checkbox' ? checked : value [name]: type === "checkbox" ? checked : value,
}) });
} };
if (!isOpen) return null if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto"> <div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Add New Role</h3> <h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Add New Role
</h3>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"> <label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Role Name Role Name
</label> </label>
<input <input
type="text" type="text"
name="role" name="role"
required required
value={formData.role} value={formData.role}
onChange={handleInputChange} onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white" className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
placeholder="e.g., host_manager, readonly" placeholder="e.g., host_manager, readonly"
/> />
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">Use lowercase with underscores (e.g., host_manager)</p> <p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
</div> Use lowercase with underscores (e.g., host_manager)
</p>
</div>
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">Permissions</h4> <h4 className="text-sm font-medium text-secondary-900 dark:text-white">
{[ Permissions
{ key: 'can_view_dashboard', label: 'View Dashboard' }, </h4>
{ key: 'can_view_hosts', label: 'View Hosts' }, {[
{ key: 'can_manage_hosts', label: 'Manage Hosts' }, { key: "can_view_dashboard", label: "View Dashboard" },
{ key: 'can_view_packages', label: 'View Packages' }, { key: "can_view_hosts", label: "View Hosts" },
{ key: 'can_manage_packages', label: 'Manage Packages' }, { key: "can_manage_hosts", label: "Manage Hosts" },
{ key: 'can_view_users', label: 'View Users' }, { key: "can_view_packages", label: "View Packages" },
{ key: 'can_manage_users', label: 'Manage Users' }, { key: "can_manage_packages", label: "Manage Packages" },
{ key: 'can_view_reports', label: 'View Reports' }, { key: "can_view_users", label: "View Users" },
{ key: 'can_export_data', label: 'Export Data' }, { key: "can_manage_users", label: "Manage Users" },
{ key: 'can_manage_settings', label: 'Manage Settings' } { key: "can_view_reports", label: "View Reports" },
].map((permission) => ( { key: "can_export_data", label: "Export Data" },
<div key={permission.key} className="flex items-center"> { key: "can_manage_settings", label: "Manage Settings" },
<input ].map((permission) => (
type="checkbox" <div key={permission.key} className="flex items-center">
name={permission.key} <input
checked={formData[permission.key]} type="checkbox"
onChange={handleInputChange} name={permission.key}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded" checked={formData[permission.key]}
/> onChange={handleInputChange}
<label className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"> className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
{permission.label} />
</label> <label className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200">
</div> {permission.label}
))} </label>
</div> </div>
))}
</div>
{error && ( {error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3"> <div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p> <p className="text-sm text-danger-700 dark:text-danger-300">
</div> {error}
)} </p>
</div>
)}
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
<button <button
type="button" type="button"
onClick={onClose} 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" 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 Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50" className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
> >
{isLoading ? 'Creating...' : 'Create Role'} {isLoading ? "Creating..." : "Create Role"}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
) );
} };
export default Permissions export default Permissions;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,369 +1,432 @@
import React, { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
ArrowLeft, Activity,
Server, AlertTriangle,
Shield, ArrowLeft,
ShieldOff, Calendar,
AlertTriangle, Database,
Users, Globe,
Globe, Lock,
Lock, Server,
Unlock, Shield,
Database, ShieldOff,
Calendar, Unlock,
Activity Users,
} from 'lucide-react'; } from "lucide-react";
import { repositoryAPI } from '../utils/api'; import React, { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { repositoryAPI } from "../utils/api";
const RepositoryDetail = () => { const RepositoryDetail = () => {
const { repositoryId } = useParams(); const { repositoryId } = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [formData, setFormData] = useState({}); const [formData, setFormData] = useState({});
// Fetch repository details // Fetch repository details
const { data: repository, isLoading, error } = useQuery({ const {
queryKey: ['repository', repositoryId], data: repository,
queryFn: () => repositoryAPI.getById(repositoryId).then(res => res.data), isLoading,
enabled: !!repositoryId error,
}); } = useQuery({
queryKey: ["repository", repositoryId],
queryFn: () => repositoryAPI.getById(repositoryId).then((res) => res.data),
enabled: !!repositoryId,
});
// Update repository mutation // Update repository mutation
const updateRepositoryMutation = useMutation({ const updateRepositoryMutation = useMutation({
mutationFn: (data) => repositoryAPI.update(repositoryId, data), mutationFn: (data) => repositoryAPI.update(repositoryId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(['repository', repositoryId]); queryClient.invalidateQueries(["repository", repositoryId]);
queryClient.invalidateQueries(['repositories']); queryClient.invalidateQueries(["repositories"]);
setEditMode(false); setEditMode(false);
} },
}); });
const handleEdit = () => {
setFormData({
name: repository.name,
description: repository.description || "",
is_active: repository.is_active,
priority: repository.priority || "",
});
setEditMode(true);
};
const handleEdit = () => { const handleSave = () => {
setFormData({ updateRepositoryMutation.mutate(formData);
name: repository.name, };
description: repository.description || '',
is_active: repository.is_active,
priority: repository.priority || ''
});
setEditMode(true);
};
const handleSave = () => { const handleCancel = () => {
updateRepositoryMutation.mutate(formData); setEditMode(false);
}; setFormData({});
};
const handleCancel = () => { if (isLoading) {
setEditMode(false); return (
setFormData({}); <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) { if (!repository) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="space-y-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> <div className="flex items-center gap-4">
</div> <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 (
return ( <div className="space-y-6">
<div className="space-y-6"> {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center justify-between">
<Link <div className="flex items-center gap-4">
to="/repositories" <Link
className="btn-outline flex items-center gap-2" to="/repositories"
> className="btn-outline flex items-center gap-2"
<ArrowLeft className="h-4 w-4" /> >
Back to Repositories <ArrowLeft className="h-4 w-4" />
</Link> Back
</div> </Link>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"> <div>
<div className="flex items-center"> <div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-red-400 mr-2" /> {repository.isSecure ? (
<span className="text-red-700 dark:text-red-300"> <Lock className="h-6 w-6 text-green-600" />
Failed to load repository: {error.message} ) : (
</span> <Unlock className="h-6 w-6 text-orange-600" />
</div> )}
</div> <h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
</div> {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) { {/* Repository Information */}
return ( <div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<div className="space-y-6"> <div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-4"> <h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
<Link Repository Information
to="/repositories" </h2>
className="btn-outline flex items-center gap-2" </div>
> <div className="px-6 py-4 space-y-4">
<ArrowLeft className="h-4 w-4" /> {editMode ? (
Back to Repositories <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
</Link> <div>
</div> <label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<div className="text-center py-12"> Repository Name
<Database className="mx-auto h-12 w-12 text-secondary-400" /> </label>
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">Repository not found</h3> <input
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300"> type="text"
The repository you're looking for doesn't exist. value={formData.name}
</p> onChange={(e) =>
</div> setFormData({ ...formData, name: e.target.value })
</div> }
); 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 ( {/* Hosts Using This Repository */}
<div className="space-y-6"> <div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
{/* Header */} <div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<div className="flex items-center justify-between"> <h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
<div className="flex items-center gap-4"> <Users className="h-5 w-5" />
<Link Hosts Using This Repository (
to="/repositories" {repository.host_repositories?.length || 0})
className="btn-outline flex items-center gap-2" </h2>
> </div>
<ArrowLeft className="h-4 w-4" /> {!repository.host_repositories ||
Back repository.host_repositories.length === 0 ? (
</Link> <div className="px-6 py-12 text-center">
<div> <Server className="mx-auto h-12 w-12 text-secondary-400" />
<div className="flex items-center gap-3"> <h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
{repository.isSecure ? ( No hosts using this repository
<Lock className="h-6 w-6 text-green-600" /> </h3>
) : ( <p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
<Unlock className="h-6 w-6 text-orange-600" /> This repository hasn't been reported by any hosts yet.
)} </p>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white"> </div>
{repository.name} ) : (
</h1> <div className="divide-y divide-secondary-200 dark:divide-secondary-700">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ {repository.host_repositories.map((hostRepo) => (
repository.is_active <div
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300' key={hostRepo.id}
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300' className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
}`}> >
{repository.is_active ? 'Active' : 'Inactive'} <div className="flex items-center justify-between">
</span> <div className="flex items-center gap-3">
</div> <div
<p className="text-secondary-500 dark:text-secondary-300 mt-1"> className={`w-3 h-3 rounded-full ${
Repository configuration and host assignments hostRepo.hosts.status === "active"
</p> ? "bg-green-500"
</div> : hostRepo.hosts.status === "pending"
</div> ? "bg-yellow-500"
<div className="flex items-center gap-2"> : "bg-red-500"
{editMode ? ( }`}
<> />
<button <div>
onClick={handleCancel} <Link
className="btn-outline" to={`/hosts/${hostRepo.hosts.id}`}
disabled={updateRepositoryMutation.isPending} className="text-primary-600 hover:text-primary-700 font-medium"
> >
Cancel {hostRepo.hosts.friendly_name}
</button> </Link>
<button <div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
onClick={handleSave} <span>IP: {hostRepo.hosts.ip}</span>
className="btn-primary" <span>
disabled={updateRepositoryMutation.isPending} OS: {hostRepo.hosts.os_type}{" "}
> {hostRepo.hosts.os_version}
{updateRepositoryMutation.isPending ? 'Saving...' : 'Save Changes'} </span>
</button> <span>
</> Last Update:{" "}
) : ( {new Date(
<button hostRepo.hosts.last_update,
onClick={handleEdit} ).toLocaleDateString()}
className="btn-primary" </span>
> </div>
Edit Repository </div>
</button> </div>
)} <div className="flex items-center gap-4">
</div> <div className="text-center">
</div> <div className="text-xs text-secondary-500 dark:text-secondary-400">
Last Checked
{/* Repository Information */} </div>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow"> <div className="text-sm text-secondary-900 dark:text-white">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700"> {new Date(hostRepo.last_checked).toLocaleDateString()}
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white"> </div>
Repository Information </div>
</h2> </div>
</div> </div>
<div className="px-6 py-4 space-y-4"> </div>
{editMode ? ( ))}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> </div>
<div> )}
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"> </div>
Repository Name </div>
</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>
);
}; };
export default RepositoryDetail; 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 // Create axios instance with default config
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
timeout: 10000, timeout: 10000,
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}) });
// Request interceptor // Request interceptor
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
// Add auth token if available // Add auth token if available
const token = localStorage.getItem('token') const token = localStorage.getItem("token");
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`;
} }
return config return config;
}, },
(error) => { (error) => {
return Promise.reject(error) return Promise.reject(error);
} },
) );
// Response interceptor // Response interceptor
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Don't redirect if we're on the login page or if it's a TFA verification error // Don't redirect if we're on the login page or if it's a TFA verification error
const currentPath = window.location.pathname const currentPath = window.location.pathname;
const isTfaError = error.config?.url?.includes('/verify-tfa') const isTfaError = error.config?.url?.includes("/verify-tfa");
if (currentPath !== '/login' && !isTfaError) { if (currentPath !== "/login" && !isTfaError) {
// Handle unauthorized // Handle unauthorized
localStorage.removeItem('token') localStorage.removeItem("token");
localStorage.removeItem('user') localStorage.removeItem("user");
localStorage.removeItem('permissions') localStorage.removeItem("permissions");
window.location.href = '/login' window.location.href = "/login";
} }
} }
return Promise.reject(error) return Promise.reject(error);
} },
) );
// Dashboard API // Dashboard API
export const dashboardAPI = { export const dashboardAPI = {
getStats: () => api.get('/dashboard/stats'), getStats: () => api.get("/dashboard/stats"),
getHosts: () => api.get('/dashboard/hosts'), getHosts: () => api.get("/dashboard/hosts"),
getPackages: () => api.get('/dashboard/packages'), getPackages: () => api.get("/dashboard/packages"),
getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`), getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
getRecentUsers: () => api.get('/dashboard/recent-users'), getRecentUsers: () => api.get("/dashboard/recent-users"),
getRecentCollection: () => api.get('/dashboard/recent-collection') getRecentCollection: () => api.get("/dashboard/recent-collection"),
} };
// Admin Hosts API (for management interface) // Admin Hosts API (for management interface)
export const adminHostsAPI = { export const adminHostsAPI = {
create: (data) => api.post('/hosts/create', data), create: (data) => api.post("/hosts/create", data),
list: () => api.get('/hosts/admin/list'), list: () => api.get("/hosts/admin/list"),
delete: (hostId) => api.delete(`/hosts/${hostId}`), delete: (hostId) => api.delete(`/hosts/${hostId}`),
deleteBulk: (hostIds) => api.delete('/hosts/bulk', { data: { hostIds } }), deleteBulk: (hostIds) => api.delete("/hosts/bulk", { data: { hostIds } }),
regenerateCredentials: (hostId) => api.post(`/hosts/${hostId}/regenerate-credentials`), regenerateCredentials: (hostId) =>
updateGroup: (hostId, hostGroupId) => api.put(`/hosts/${hostId}/group`, { hostGroupId }), api.post(`/hosts/${hostId}/regenerate-credentials`),
bulkUpdateGroup: (hostIds, hostGroupId) => api.put('/hosts/bulk/group', { hostIds, hostGroupId }), updateGroup: (hostId, hostGroupId) =>
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }), api.put(`/hosts/${hostId}/group`, { hostGroupId }),
updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendly_name: friendlyName }) bulkUpdateGroup: (hostIds, hostGroupId) =>
} api.put("/hosts/bulk/group", { hostIds, hostGroupId }),
toggleAutoUpdate: (hostId, autoUpdate) =>
api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
updateFriendlyName: (hostId, friendlyName) =>
api.patch(`/hosts/${hostId}/friendly-name`, {
friendly_name: friendlyName,
}),
};
// Host Groups API // Host Groups API
export const hostGroupsAPI = { export const hostGroupsAPI = {
list: () => api.get('/host-groups'), list: () => api.get("/host-groups"),
get: (id) => api.get(`/host-groups/${id}`), get: (id) => api.get(`/host-groups/${id}`),
create: (data) => api.post('/host-groups', data), create: (data) => api.post("/host-groups", data),
update: (id, data) => api.put(`/host-groups/${id}`, data), update: (id, data) => api.put(`/host-groups/${id}`, data),
delete: (id) => api.delete(`/host-groups/${id}`), delete: (id) => api.delete(`/host-groups/${id}`),
getHosts: (id) => api.get(`/host-groups/${id}/hosts`), getHosts: (id) => api.get(`/host-groups/${id}/hosts`),
} };
// Admin Users API (for user management) // Admin Users API (for user management)
export const adminUsersAPI = { export const adminUsersAPI = {
list: () => api.get('/auth/admin/users'), list: () => api.get("/auth/admin/users"),
create: (userData) => api.post('/auth/admin/users', userData), create: (userData) => api.post("/auth/admin/users", userData),
update: (userId, userData) => api.put(`/auth/admin/users/${userId}`, userData), update: (userId, userData) =>
delete: (userId) => api.delete(`/auth/admin/users/${userId}`), api.put(`/auth/admin/users/${userId}`, userData),
resetPassword: (userId, newPassword) => api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword }) 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) // Permissions API (for role management)
export const permissionsAPI = { export const permissionsAPI = {
getRoles: () => api.get('/permissions/roles'), getRoles: () => api.get("/permissions/roles"),
getRole: (role) => api.get(`/permissions/roles/${role}`), getRole: (role) => api.get(`/permissions/roles/${role}`),
updateRole: (role, permissions) => api.put(`/permissions/roles/${role}`, permissions), updateRole: (role, permissions) =>
deleteRole: (role) => api.delete(`/permissions/roles/${role}`), api.put(`/permissions/roles/${role}`, permissions),
getUserPermissions: () => api.get('/permissions/user-permissions') deleteRole: (role) => api.delete(`/permissions/roles/${role}`),
} getUserPermissions: () => api.get("/permissions/user-permissions"),
};
// Settings API // Settings API
export const settingsAPI = { export const settingsAPI = {
get: () => api.get('/settings'), get: () => api.get("/settings"),
update: (settings) => api.put('/settings', settings), update: (settings) => api.put("/settings", settings),
getServerUrl: () => api.get('/settings/server-url') getServerUrl: () => api.get("/settings/server-url"),
} };
// Agent Version API // Agent Version API
export const agentVersionAPI = { export const agentVersionAPI = {
list: () => api.get('/hosts/agent/versions'), list: () => api.get("/hosts/agent/versions"),
create: (data) => api.post('/hosts/agent/versions', data), create: (data) => api.post("/hosts/agent/versions", data),
update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data), update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data),
delete: (id) => api.delete(`/hosts/agent/versions/${id}`), delete: (id) => api.delete(`/hosts/agent/versions/${id}`),
setCurrent: (id) => api.patch(`/hosts/agent/versions/${id}/current`), setCurrent: (id) => api.patch(`/hosts/agent/versions/${id}/current`),
setDefault: (id) => api.patch(`/hosts/agent/versions/${id}/default`), setDefault: (id) => api.patch(`/hosts/agent/versions/${id}/default`),
download: (version) => api.get(`/hosts/agent/download${version ? `?version=${version}` : ''}`, { responseType: 'blob' }) download: (version) =>
} api.get(`/hosts/agent/download${version ? `?version=${version}` : ""}`, {
responseType: "blob",
}),
};
// Repository API // Repository API
export const repositoryAPI = { export const repositoryAPI = {
list: () => api.get('/repositories'), list: () => api.get("/repositories"),
getById: (repositoryId) => api.get(`/repositories/${repositoryId}`), getById: (repositoryId) => api.get(`/repositories/${repositoryId}`),
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`), getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
update: (repositoryId, data) => api.put(`/repositories/${repositoryId}`, data), update: (repositoryId, data) =>
toggleHostRepository: (hostId, repositoryId, isEnabled) => api.put(`/repositories/${repositoryId}`, data),
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, { isEnabled }), toggleHostRepository: (hostId, repositoryId, isEnabled) =>
getStats: () => api.get('/repositories/stats/summary'), api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, {
cleanupOrphaned: () => api.delete('/repositories/cleanup/orphaned') isEnabled,
} }),
getStats: () => api.get("/repositories/stats/summary"),
cleanupOrphaned: () => api.delete("/repositories/cleanup/orphaned"),
};
// Dashboard Preferences API // Dashboard Preferences API
export const dashboardPreferencesAPI = { export const dashboardPreferencesAPI = {
get: () => api.get('/dashboard-preferences'), get: () => api.get("/dashboard-preferences"),
update: (preferences) => api.put('/dashboard-preferences', { preferences }), update: (preferences) => api.put("/dashboard-preferences", { preferences }),
getDefaults: () => api.get('/dashboard-preferences/defaults') getDefaults: () => api.get("/dashboard-preferences/defaults"),
} };
// Hosts API (for agent communication - kept for compatibility) // Hosts API (for agent communication - kept for compatibility)
export const hostsAPI = { export const hostsAPI = {
// Legacy register endpoint (now deprecated) // Legacy register endpoint (now deprecated)
register: (data) => api.post('/hosts/register', data), register: (data) => api.post("/hosts/register", data),
// Updated to use API credentials // Updated to use API credentials
update: (apiId, apiKey, data) => api.post('/hosts/update', data, { update: (apiId, apiKey, data) =>
headers: { api.post("/hosts/update", data, {
'X-API-ID': apiId, headers: {
'X-API-KEY': apiKey "X-API-ID": apiId,
} "X-API-KEY": apiKey,
}), },
getInfo: (apiId, apiKey) => api.get('/hosts/info', { }),
headers: { getInfo: (apiId, apiKey) =>
'X-API-ID': apiId, api.get("/hosts/info", {
'X-API-KEY': apiKey headers: {
} "X-API-ID": apiId,
}), "X-API-KEY": apiKey,
ping: (apiId, apiKey) => api.post('/hosts/ping', {}, { },
headers: { }),
'X-API-ID': apiId, ping: (apiId, apiKey) =>
'X-API-KEY': apiKey api.post(
} "/hosts/ping",
}), {},
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate }) {
} headers: {
"X-API-ID": apiId,
"X-API-KEY": apiKey,
},
},
),
toggleAutoUpdate: (id, autoUpdate) =>
api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate }),
};
// Packages API // Packages API
export const packagesAPI = { export const packagesAPI = {
getAll: (params = {}) => api.get('/packages', { params }), getAll: (params = {}) => api.get("/packages", { params }),
getById: (packageId) => api.get(`/packages/${packageId}`), getById: (packageId) => api.get(`/packages/${packageId}`),
getCategories: () => api.get('/packages/categories/list'), getCategories: () => api.get("/packages/categories/list"),
getHosts: (packageId, params = {}) => api.get(`/packages/${packageId}/hosts`, { params }), getHosts: (packageId, params = {}) =>
update: (packageId, data) => api.put(`/packages/${packageId}`, data), api.get(`/packages/${packageId}/hosts`, { params }),
search: (query, params = {}) => api.get(`/packages/search/${query}`, { params }), update: (packageId, data) => api.put(`/packages/${packageId}`, data),
} search: (query, params = {}) =>
api.get(`/packages/search/${query}`, { params }),
};
// Utility functions // Utility functions
export const formatError = (error) => { export const formatError = (error) => {
if (error.response?.data?.message) { if (error.response?.data?.message) {
return error.response.data.message return error.response.data.message;
} }
if (error.response?.data?.error) { if (error.response?.data?.error) {
return error.response.data.error return error.response.data.error;
} }
if (error.message) { if (error.message) {
return error.message return error.message;
} }
return 'An unexpected error occurred' return "An unexpected error occurred";
} };
export const formatDate = (date) => { export const formatDate = (date) => {
return new Date(date).toLocaleString() return new Date(date).toLocaleString();
} };
// Version API // Version API
export const versionAPI = { export const versionAPI = {
getCurrent: () => api.get('/version/current'), getCurrent: () => api.get("/version/current"),
checkUpdates: () => api.get('/version/check-updates'), checkUpdates: () => api.get("/version/check-updates"),
testSshKey: (data) => api.post('/version/test-ssh-key', data), testSshKey: (data) => api.post("/version/test-ssh-key", data),
} };
// Auth API // Auth API
export const authAPI = { export const authAPI = {
login: (username, password) => api.post('/auth/login', { username, password }), login: (username, password) =>
verifyTfa: (username, token) => api.post('/auth/verify-tfa', { username, token }), api.post("/auth/login", { username, password }),
signup: (username, email, password, firstName, lastName) => api.post('/auth/signup', { username, email, password, firstName, lastName }), verifyTfa: (username, token) =>
} api.post("/auth/verify-tfa", { username, token }),
signup: (username, email, password, firstName, lastName) =>
api.post("/auth/signup", {
username,
email,
password,
firstName,
lastName,
}),
};
// TFA API // TFA API
export const tfaAPI = { export const tfaAPI = {
setup: () => api.get('/tfa/setup'), setup: () => api.get("/tfa/setup"),
verifySetup: (data) => api.post('/tfa/verify-setup', data), verifySetup: (data) => api.post("/tfa/verify-setup", data),
disable: (data) => api.post('/tfa/disable', data), disable: (data) => api.post("/tfa/disable", data),
status: () => api.get('/tfa/status'), status: () => api.get("/tfa/status"),
regenerateBackupCodes: () => api.post('/tfa/regenerate-backup-codes'), regenerateBackupCodes: () => api.post("/tfa/regenerate-backup-codes"),
verify: (data) => api.post('/tfa/verify', data), verify: (data) => api.post("/tfa/verify", data),
} };
export const formatRelativeTime = (date) => { export const formatRelativeTime = (date) => {
const now = new Date() const now = new Date();
const diff = now - new Date(date) const diff = now - new Date(date);
const seconds = Math.floor(diff / 1000) const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60) const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24);
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago` if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago` if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago` if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
return `${seconds} second${seconds > 1 ? 's' : ''} ago` return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
} };
export default api export default api;

View File

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

View File

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

View File

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