mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-09 16:37:29 +00:00
style(frontend): fmt
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,45 +1,50 @@
|
|||||||
import express from 'express';
|
import cors from "cors";
|
||||||
import path from 'path';
|
import express from "express";
|
||||||
import cors from 'cors';
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
||||||
import { fileURLToPath } from 'url';
|
import path from "path";
|
||||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend:3001';
|
const BACKEND_URL = process.env.BACKEND_URL || "http://backend:3001";
|
||||||
|
|
||||||
// Enable CORS for API calls
|
// Enable CORS for API calls
|
||||||
app.use(cors({
|
app.use(
|
||||||
origin: process.env.CORS_ORIGIN || '*',
|
cors({
|
||||||
credentials: true
|
origin: process.env.CORS_ORIGIN || "*",
|
||||||
}));
|
credentials: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Proxy API requests to backend
|
// Proxy API requests to backend
|
||||||
app.use('/api', createProxyMiddleware({
|
app.use(
|
||||||
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")}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import React from 'react'
|
import { ArrowUpCircle } from "lucide-react";
|
||||||
import { ArrowUpCircle } from 'lucide-react'
|
import React from "react";
|
||||||
|
|
||||||
const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
|
const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
|
||||||
if (!show) return null
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArrowUpCircle
|
<ArrowUpCircle
|
||||||
className={`${className} text-red-500 animate-pulse`}
|
className={`${className} text-red-500 animate-pulse`}
|
||||||
title="Update available"
|
title="Update available"
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default UpgradeNotificationIcon
|
export default UpgradeNotificationIcon;
|
||||||
|
|||||||
@@ -1,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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,54 +1,52 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
const ThemeContext = createContext()
|
const ThemeContext = createContext();
|
||||||
|
|
||||||
export const useTheme = () => {
|
export const useTheme = () => {
|
||||||
const context = useContext(ThemeContext)
|
const context = useContext(ThemeContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useTheme must be used within a ThemeProvider')
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
}
|
}
|
||||||
return context
|
return context;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ThemeProvider = ({ children }) => {
|
export const ThemeProvider = ({ children }) => {
|
||||||
const [theme, setTheme] = useState(() => {
|
const [theme, setTheme] = useState(() => {
|
||||||
// Check localStorage first, then system preference
|
// Check localStorage first, then system preference
|
||||||
const savedTheme = localStorage.getItem('theme')
|
const savedTheme = localStorage.getItem("theme");
|
||||||
if (savedTheme) {
|
if (savedTheme) {
|
||||||
return savedTheme
|
return savedTheme;
|
||||||
}
|
}
|
||||||
// Check system preference
|
// Check system preference
|
||||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||||
return 'dark'
|
return "dark";
|
||||||
}
|
}
|
||||||
return 'light'
|
return "light";
|
||||||
})
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Apply theme to document
|
// Apply theme to document
|
||||||
if (theme === 'dark') {
|
if (theme === "dark") {
|
||||||
document.documentElement.classList.add('dark')
|
document.documentElement.classList.add("dark");
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark')
|
document.documentElement.classList.remove("dark");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
localStorage.setItem('theme', theme)
|
localStorage.setItem("theme", theme);
|
||||||
}, [theme])
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
|
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
|
||||||
}
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
theme,
|
theme,
|
||||||
toggleTheme,
|
toggleTheme,
|
||||||
isDark: theme === 'dark'
|
isDark: theme === "dark",
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={value}>
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
{children}
|
);
|
||||||
</ThemeContext.Provider>
|
};
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,58 +1,64 @@
|
|||||||
import React, { createContext, useContext, useState } from 'react'
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import React, { createContext, useContext, useState } from "react";
|
||||||
import { versionAPI, settingsAPI } from '../utils/api'
|
import { settingsAPI, versionAPI } from "../utils/api";
|
||||||
import { useAuth } from './AuthContext'
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
const UpdateNotificationContext = createContext()
|
const UpdateNotificationContext = createContext();
|
||||||
|
|
||||||
export const useUpdateNotification = () => {
|
export const useUpdateNotification = () => {
|
||||||
const context = useContext(UpdateNotificationContext)
|
const context = useContext(UpdateNotificationContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useUpdateNotification must be used within an UpdateNotificationProvider')
|
throw new Error(
|
||||||
}
|
"useUpdateNotification must be used within an UpdateNotificationProvider",
|
||||||
return context
|
);
|
||||||
}
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
export const UpdateNotificationProvider = ({ children }) => {
|
export const UpdateNotificationProvider = ({ children }) => {
|
||||||
const [dismissed, setDismissed] = useState(false)
|
const [dismissed, setDismissed] = useState(false);
|
||||||
const { user, token } = useAuth()
|
const { user, token } = useAuth();
|
||||||
|
|
||||||
// Ensure settings are loaded
|
// Ensure settings are loaded
|
||||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||||
queryKey: ['settings'],
|
queryKey: ["settings"],
|
||||||
queryFn: () => settingsAPI.get().then(res => res.data),
|
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||||
enabled: !!(user && token),
|
enabled: !!(user && token),
|
||||||
retry: 1
|
retry: 1,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Query for update information
|
// Query for update information
|
||||||
const { data: updateData, isLoading, error } = useQuery({
|
const {
|
||||||
queryKey: ['updateCheck'],
|
data: updateData,
|
||||||
queryFn: () => versionAPI.checkUpdates().then(res => res.data),
|
isLoading,
|
||||||
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>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: [],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user