Added settings pages to bring all the settings together from patchmon options, profile page and server settings.

This commit is contained in:
Muhammad Ibrahim
2025-09-30 19:48:28 +01:00
parent 8ca22dc7ab
commit ed0cf79b53
16 changed files with 4638 additions and 149 deletions

View File

@@ -205,11 +205,11 @@ router.delete(
return res.status(404).json({ error: "Host group not found" });
}
// Check if host group has hosts
// If host group has hosts, ungroup them first
if (existingGroup._count.hosts > 0) {
return res.status(400).json({
error:
"Cannot delete host group that contains hosts. Please move or remove hosts first.",
await prisma.hosts.updateMany({
where: { host_group_id: id },
data: { host_group_id: null },
});
}

View File

@@ -2,6 +2,7 @@ import { Route, Routes } from "react-router-dom";
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute";
import SettingsLayout from "./components/SettingsLayout";
import { isAuthPhase } from "./constants/authPhases";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { ThemeProvider } from "./contexts/ThemeContext";
@@ -10,15 +11,16 @@ import Dashboard from "./pages/Dashboard";
import HostDetail from "./pages/HostDetail";
import Hosts from "./pages/Hosts";
import Login from "./pages/Login";
import Options from "./pages/Options";
import PackageDetail from "./pages/PackageDetail";
import Packages from "./pages/Packages";
import Permissions from "./pages/Permissions";
import Profile from "./pages/Profile";
import Repositories from "./pages/Repositories";
import RepositoryDetail from "./pages/RepositoryDetail";
import Settings from "./pages/Settings";
import Users from "./pages/Users";
import SettingsAgentConfig from "./pages/settings/SettingsAgentConfig";
import SettingsHostGroups from "./pages/settings/SettingsHostGroups";
import SettingsServerConfig from "./pages/settings/SettingsServerConfig";
import SettingsUsers from "./pages/settings/SettingsUsers";
function AppRoutes() {
const { needsFirstTimeSetup, authPhase, isAuthenticated } = useAuth();
@@ -114,7 +116,7 @@ function AppRoutes() {
element={
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<Users />
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
@@ -124,13 +126,115 @@ function AppRoutes() {
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Permissions />
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/users"
element={
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/roles"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/profile"
element={
<ProtectedRoute>
<Layout>
<SettingsLayout>
<Profile />
</SettingsLayout>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/host-groups"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsHostGroups />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/notifications"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsHostGroups />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-config"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-config/management"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-config"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-config/version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/alert-channels"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
@@ -139,22 +243,42 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/settings/server-url"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/options"
element={
<ProtectedRoute requirePermission="can_manage_hosts">
<Layout>
<Options />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Layout>
<Profile />
<SettingsHostGroups />
</Layout>
</ProtectedRoute>
}

View File

@@ -141,69 +141,41 @@ const Layout = ({ children }) => {
}
}
// PatchMon Users section - only show if user can view/manage users
if (canViewUsers() || canManageUsers()) {
const userItems = [];
if (canViewUsers()) {
userItems.push({ name: "Users", href: "/users", icon: Users });
}
if (canManageSettings()) {
userItems.push({
name: "Permissions",
href: "/permissions",
icon: Shield,
});
}
if (userItems.length > 0) {
nav.push({
section: "PatchMon Users",
items: userItems,
});
}
}
// Settings section - only show if user has any settings permissions
if (canManageSettings() || canViewReports() || canExportData()) {
const settingsItems = [];
if (canManageSettings()) {
settingsItems.push({
name: "PatchMon Options",
href: "/options",
icon: Settings,
});
settingsItems.push({
name: "Server Config",
href: "/settings",
icon: Wrench,
showUpgradeIcon: updateAvailable,
});
}
if (canViewReports() || canExportData()) {
settingsItems.push({
name: "Audit Log",
href: "/audit-log",
icon: FileText,
comingSoon: true,
});
}
if (settingsItems.length > 0) {
nav.push({
section: "Settings",
items: settingsItems,
});
}
}
return nav;
};
// Build settings navigation separately (for bottom placement)
const buildSettingsNavigation = () => {
const settingsNav = [];
// Settings section - consolidated all settings into one page
if (
canManageSettings() ||
canViewUsers() ||
canManageUsers() ||
canViewReports() ||
canExportData()
) {
const settingsItems = [];
settingsItems.push({
name: "Settings",
href: "/settings/users",
icon: Settings,
showUpgradeIcon: updateAvailable,
});
settingsNav.push({
section: "Settings",
items: settingsItems,
});
}
return settingsNav;
};
const navigation = buildNavigation();
const settingsNavigation = buildSettingsNavigation();
const isActive = (path) => location.pathname === path;
@@ -223,9 +195,10 @@ const Layout = ({ children }) => {
if (path === "/settings") return "Settings";
if (path === "/options") return "PatchMon Options";
if (path === "/audit-log") return "Audit Log";
if (path === "/profile") return "My Profile";
if (path === "/settings/profile") return "My Profile";
if (path.startsWith("/hosts/")) return "Host Details";
if (path.startsWith("/packages/")) return "Package Details";
if (path.startsWith("/settings/")) return "Settings";
return "PatchMon";
};
@@ -342,7 +315,7 @@ const Layout = ({ children }) => {
</div>
<nav className="mt-8 flex-1 space-y-6 px-2">
{/* Show message for users with very limited permissions */}
{navigation.length === 0 && (
{navigation.length === 0 && settingsNavigation.length === 0 && (
<div className="px-2 py-4 text-center">
<div className="text-sm text-secondary-500 dark:text-secondary-400">
<p className="mb-2">Limited access</p>
@@ -394,6 +367,12 @@ const Layout = ({ children }) => {
<subItem.icon className="mr-3 h-5 w-5" />
<span className="flex items-center gap-2 flex-1">
{subItem.name}
{subItem.name === "Hosts" &&
stats?.cards?.totalHosts !== undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalHosts}
</span>
)}
</span>
<button
type="button"
@@ -426,6 +405,12 @@ const Layout = ({ children }) => {
<subItem.icon className="mr-3 h-5 w-5" />
<span className="flex items-center gap-2">
{subItem.name}
{subItem.name === "Hosts" &&
stats?.cards?.totalHosts !== undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalHosts}
</span>
)}
{subItem.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
@@ -442,6 +427,40 @@ const Layout = ({ children }) => {
}
return null;
})}
{/* Settings Section - Mobile */}
{settingsNavigation.map((item) => {
if (item.section) {
// Settings section (no heading)
return (
<div key={item.section}>
<div className="space-y-1">
{item.items.map((subItem) => (
<Link
key={subItem.name}
to={subItem.href}
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
isActive(subItem.href)
? "bg-primary-100 text-primary-900"
: "text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900"
}`}
onClick={() => setSidebarOpen(false)}
>
<subItem.icon className="mr-3 h-5 w-5" />
<span className="flex items-center gap-2">
{subItem.name}
{subItem.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3" />
)}
</span>
</Link>
))}
</div>
</div>
);
}
return null;
})}
</nav>
</div>
</div>
@@ -493,7 +512,7 @@ const Layout = ({ children }) => {
<nav className="flex flex-1 flex-col">
<ul className="flex flex-1 flex-col gap-y-6">
{/* Show message for users with very limited permissions */}
{navigation.length === 0 && (
{navigation.length === 0 && settingsNavigation.length === 0 && (
<li className="px-2 py-4 text-center">
<div className="text-sm text-secondary-500 dark:text-secondary-400">
<p className="mb-2">Limited access</p>
@@ -558,6 +577,13 @@ const Layout = ({ children }) => {
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2 flex-1">
{subItem.name}
{subItem.name === "Hosts" &&
stats?.cards?.totalHosts !==
undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalHosts}
</span>
)}
</span>
)}
{!sidebarCollapsed && (
@@ -609,6 +635,13 @@ const Layout = ({ children }) => {
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2">
{subItem.name}
{subItem.name === "Hosts" &&
stats?.cards?.totalHosts !==
undefined && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
{stats.cards.totalHosts}
</span>
)}
{subItem.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
@@ -630,6 +663,59 @@ const Layout = ({ children }) => {
return null;
})}
</ul>
{/* Settings Section - Bottom of Navigation */}
{settingsNavigation.length > 0 && (
<ul className="gap-y-6">
{settingsNavigation.map((item) => {
if (item.section) {
// Settings section (no heading)
return (
<li key={item.section}>
<ul
className={`space-y-1 ${sidebarCollapsed ? "" : "-mx-2"}`}
>
{item.items.map((subItem) => (
<li key={subItem.name}>
<Link
to={subItem.href}
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 ${
isActive(subItem.href)
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
} ${sidebarCollapsed ? "justify-center p-2 relative" : "p-2"}`}
title={sidebarCollapsed ? subItem.name : ""}
>
<div
className={`flex items-center ${sidebarCollapsed ? "justify-center" : ""}`}
>
<subItem.icon
className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? "mx-auto" : ""}`}
/>
{sidebarCollapsed &&
subItem.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3 absolute -top-1 -right-1" />
)}
</div>
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2">
{subItem.name}
{subItem.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3" />
)}
</span>
)}
</Link>
</li>
))}
</ul>
</li>
);
}
return null;
})}
</ul>
)}
</nav>
{/* Profile Section - Bottom of Sidebar */}
@@ -637,11 +723,11 @@ const Layout = ({ children }) => {
{!sidebarCollapsed ? (
<div>
{/* User Info with Sign Out - Username is clickable */}
<div className="flex items-center justify-between p-2">
<div className="flex items-center justify-between -mx-2 py-2">
<Link
to="/profile"
to="/settings/profile"
className={`flex-1 min-w-0 rounded-md p-2 transition-all duration-200 ${
isActive("/profile")
isActive("/settings/profile")
? "bg-primary-50 dark:bg-primary-600"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
@@ -649,15 +735,15 @@ const Layout = ({ children }) => {
<div className="flex items-center gap-x-3">
<UserCircle
className={`h-5 w-5 shrink-0 ${
isActive("/profile")
isActive("/settings/profile")
? "text-primary-700 dark:text-white"
: "text-secondary-500 dark:text-secondary-400"
}`}
/>
<div className="flex items-center gap-x-2">
<div className="flex flex-col min-w-0">
<span
className={`text-sm leading-6 font-semibold truncate ${
isActive("/profile")
isActive("/settings/profile")
? "text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200"
}`}
@@ -665,8 +751,14 @@ const Layout = ({ children }) => {
{user?.first_name || user?.username}
</span>
{user?.role === "admin" && (
<span className="inline-flex items-center rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-800">
Admin
<span
className={`text-xs leading-4 ${
isActive("/settings/profile")
? "text-primary-600 dark:text-primary-200"
: "text-secondary-500 dark:text-secondary-400"
}`}
>
Role: Admin
</span>
)}
</div>
@@ -712,9 +804,9 @@ const Layout = ({ children }) => {
) : (
<div className="space-y-1">
<Link
to="/profile"
to="/settings/profile"
className={`flex items-center justify-center p-2 rounded-md transition-colors ${
isActive("/profile")
isActive("/settings/profile")
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}

View File

@@ -0,0 +1,248 @@
import {
AlertTriangle,
ArrowLeft,
Bell,
ChevronLeft,
ChevronRight,
Code,
Folder,
RefreshCw,
Server,
Settings,
Shield,
UserCircle,
Users,
Wrench,
} from "lucide-react";
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const SettingsLayout = ({ children }) => {
const location = useLocation();
const { canManageSettings, canViewUsers, canManageUsers } = useAuth();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Build secondary navigation based on permissions
const buildSecondaryNavigation = () => {
const nav = [];
// Users section
if (canViewUsers() || canManageUsers()) {
nav.push({
section: "User Management",
items: [
{
name: "Users",
href: "/settings/users",
icon: Users,
},
{
name: "Roles",
href: "/settings/roles",
icon: Shield,
},
{
name: "My Profile",
href: "/settings/profile",
icon: UserCircle,
},
],
});
}
// Host Groups
if (canManageSettings()) {
nav.push({
section: "Hosts Management",
items: [
{
name: "Host Groups",
href: "/settings/host-groups",
icon: Folder,
},
{
name: "Agent Updates",
href: "/settings/agent-config",
icon: RefreshCw,
},
{
name: "Agent Version",
href: "/settings/agent-version",
icon: Settings,
},
],
});
}
// Alert Management
if (canManageSettings()) {
nav.push({
section: "Alert Management",
items: [
{
name: "Alert Channels",
href: "/settings/alert-channels",
icon: Bell,
},
{
name: "Notifications",
href: "/settings/notifications",
icon: Bell,
comingSoon: true,
},
],
});
}
// Server Config
if (canManageSettings()) {
nav.push({
section: "Server",
items: [
{
name: "URL Config",
href: "/settings/server-url",
icon: Wrench,
},
{
name: "Server Version",
href: "/settings/server-version",
icon: Code,
},
],
});
}
return nav;
};
const secondaryNavigation = buildSecondaryNavigation();
const isActive = (path) => location.pathname === path;
const getPageTitle = () => {
const path = location.pathname;
if (path.startsWith("/settings/users")) return "Users";
if (path.startsWith("/settings/host-groups")) return "Host Groups";
if (path.startsWith("/settings/notifications")) return "Notifications";
if (path.startsWith("/settings/agent-config")) return "Agent Config";
if (path.startsWith("/settings/server-config")) return "Server Config";
return "Settings";
};
return (
<div className="bg-transparent">
{/* Within-page secondary navigation and content */}
<div className="px-2 sm:px-4 lg:px-6">
<div className="flex gap-4">
{/* Left secondary nav (within page) */}
<aside
className={`${sidebarCollapsed ? "w-14" : "w-56"} transition-all duration-300 flex-shrink-0`}
>
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg">
{/* Collapse button */}
<div className="flex justify-end p-2 border-b border-secondary-200 dark:border-secondary-600">
<button
type="button"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 rounded transition-colors"
title={
sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
}
>
{sidebarCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</button>
</div>
<div className={`${sidebarCollapsed ? "p-2" : "p-3"}`}>
<nav>
<ul
className={`${sidebarCollapsed ? "space-y-2" : "space-y-4"}`}
>
{secondaryNavigation.map((item) => (
<li key={item.section}>
{!sidebarCollapsed && (
<h4 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2">
{item.section}
</h4>
)}
<ul
className={`${sidebarCollapsed ? "space-y-1" : "space-y-1"}`}
>
{item.items.map((subItem) => (
<li key={subItem.name}>
<Link
to={subItem.href}
className={`group flex items-center rounded-md text-sm leading-5 font-medium transition-colors ${
sidebarCollapsed
? "justify-center p-2"
: "gap-2 p-2"
} ${
isActive(subItem.href)
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
title={sidebarCollapsed ? subItem.name : ""}
>
<subItem.icon className="h-4 w-4 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2">
{subItem.name}
{subItem.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
</span>
)}
</span>
)}
</Link>
{!sidebarCollapsed && subItem.subTabs && (
<ul className="ml-6 mt-1 space-y-1">
{subItem.subTabs.map((subTab) => (
<li key={subTab.name}>
<Link
to={subTab.href}
className={`block px-3 py-1 text-xs font-medium rounded transition-colors ${
isActive(subTab.href)
? "bg-primary-100 dark:bg-primary-700 text-primary-700 dark:text-primary-200"
: "text-secondary-600 dark:text-secondary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
{subTab.name}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</li>
))}
</ul>
</nav>
</div>
</div>
</aside>
{/* Right content */}
<section className="flex-1 min-w-0">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-4">
{children}
</div>
</section>
</div>
</div>
</div>
);
};
export default SettingsLayout;

View File

@@ -0,0 +1,368 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, Code, Download, Plus, Shield, X } from "lucide-react";
import { useId, useState } from "react";
import { agentFileAPI } from "../../utils/api";
const AgentManagementTab = () => {
const scriptFileId = useId();
const scriptContentId = useId();
const [showUploadModal, setShowUploadModal] = useState(false);
// Agent file queries and mutations
const {
data: agentFileInfo,
isLoading: agentFileLoading,
error: agentFileError,
refetch: refetchAgentFile,
} = useQuery({
queryKey: ["agentFile"],
queryFn: () => agentFileAPI.getInfo().then((res) => res.data),
});
const uploadAgentMutation = useMutation({
mutationFn: (scriptContent) =>
agentFileAPI.upload(scriptContent).then((res) => res.data),
onSuccess: () => {
refetchAgentFile();
setShowUploadModal(false);
},
onError: (error) => {
console.error("Upload agent error:", error);
},
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center mb-2">
<Code className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Agent File Management
</h2>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">
Manage the PatchMon agent script file used for installations and
updates
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const url = "/api/v1/hosts/agent/download";
const link = document.createElement("a");
link.href = url;
link.download = "patchmon-agent.sh";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
className="btn-outline flex items-center gap-2"
>
<Download className="h-4 w-4" />
Download
</button>
<button
type="button"
onClick={() => setShowUploadModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Replace Script
</button>
</div>
</div>
{/* Content */}
{agentFileLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : agentFileError ? (
<div className="text-center py-8">
<p className="text-red-600 dark:text-red-400">
Error loading agent file: {agentFileError.message}
</p>
</div>
) : !agentFileInfo?.exists ? (
<div className="text-center py-8">
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
No agent script found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Upload an agent script to get started
</p>
</div>
) : (
<div className="space-y-6">
{/* Agent File Info */}
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Current Agent Script
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Version:
</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentFileInfo.version}
</span>
</div>
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Size:
</span>
<span className="text-sm text-secondary-900 dark:text-white">
{agentFileInfo.sizeFormatted}
</span>
</div>
<div className="flex items-center gap-2">
<Code className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Modified:
</span>
<span className="text-sm text-secondary-900 dark:text-white">
{new Date(agentFileInfo.lastModified).toLocaleDateString()}
</span>
</div>
</div>
</div>
{/* Usage Instructions */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
Agent Script Usage
</h3>
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
<p className="mb-2">This script is used for:</p>
<ul className="list-disc list-inside space-y-1">
<li>New agent installations via the install script</li>
<li>
Agent downloads from the /api/v1/hosts/agent/download
endpoint
</li>
<li>Manual agent deployments and updates</li>
</ul>
</div>
</div>
</div>
</div>
{/* Uninstall Instructions */}
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<Shield className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Agent Uninstall Command
</h3>
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
<p className="mb-2">
To completely remove PatchMon from a host:
</p>
<div className="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
curl -ks {window.location.origin}
/api/v1/hosts/remove | sudo bash
</div>
<button
type="button"
onClick={() => {
const command = `curl -ks ${window.location.origin}/api/v1/hosts/remove | sudo bash`;
navigator.clipboard.writeText(command);
// You could add a toast notification here
}}
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
>
Copy
</button>
</div>
<p className="mt-2 text-xs">
This will remove all PatchMon files, configuration, and
crontab entries
</p>
</div>
</div>
</div>
</div>
</div>
)}
{/* Agent Upload Modal */}
{showUploadModal && (
<AgentUploadModal
isOpen={showUploadModal}
onClose={() => setShowUploadModal(false)}
onSubmit={uploadAgentMutation.mutate}
isLoading={uploadAgentMutation.isPending}
error={uploadAgentMutation.error}
scriptFileId={scriptFileId}
scriptContentId={scriptContentId}
/>
)}
</div>
);
};
// Agent Upload Modal Component
const AgentUploadModal = ({
isOpen,
onClose,
onSubmit,
isLoading,
error,
scriptFileId,
scriptContentId,
}) => {
const [scriptContent, setScriptContent] = useState("");
const [uploadError, setUploadError] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
setUploadError("");
if (!scriptContent.trim()) {
setUploadError("Script content is required");
return;
}
if (!scriptContent.trim().startsWith("#!/")) {
setUploadError(
"Script must start with a shebang (#!/bin/bash or #!/bin/sh)",
);
return;
}
onSubmit(scriptContent);
};
const handleFileUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
setScriptContent(event.target.result);
setUploadError("");
};
reader.readAsText(file);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Replace Agent Script
</h3>
<button
type="button"
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4">
<div className="space-y-4">
<div>
<label
htmlFor={scriptFileId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Upload Script File
</label>
<input
id={scriptFileId}
type="file"
accept=".sh"
onChange={handleFileUpload}
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Select a .sh file to upload, or paste the script content below
</p>
</div>
<div>
<label
htmlFor={scriptContentId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Script Content *
</label>
<textarea
id={scriptContentId}
value={scriptContent}
onChange={(e) => {
setScriptContent(e.target.value);
setUploadError("");
}}
rows={15}
className="block w-full border 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 font-mono text-sm"
placeholder="#!/bin/bash&#10;&#10;# PatchMon Agent Script&#10;VERSION=&quot;1.0.0&quot;&#10;&#10;# Your script content here..."
/>
</div>
{(uploadError || error) && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
<p className="text-sm text-red-800 dark:text-red-200">
{uploadError ||
error?.response?.data?.error ||
error?.message}
</p>
</div>
)}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
<div className="flex">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
<div className="text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-medium">Important:</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>This will replace the current agent script file</li>
<li>A backup will be created automatically</li>
<li>All new installations will use this script</li>
<li>
Existing agents will download this version on their next
update
</li>
</ul>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button type="button" onClick={onClose} className="btn-outline">
Cancel
</button>
<button
type="submit"
disabled={isLoading || !scriptContent.trim()}
className="btn-primary"
>
{isLoading ? "Uploading..." : "Replace Script"}
</button>
</div>
</form>
</div>
</div>
);
};
export default AgentManagementTab;

View File

@@ -0,0 +1,425 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle, Save, Shield } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { permissionsAPI, settingsAPI } from "../../utils/api";
const AgentUpdatesTab = () => {
const updateIntervalId = useId();
const autoUpdateId = useId();
const signupEnabledId = useId();
const defaultRoleId = useId();
const [formData, setFormData] = useState({
updateInterval: 60,
autoUpdate: false,
signupEnabled: false,
defaultUserRole: "user",
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
const queryClient = useQueryClient();
// Fetch current settings
const {
data: settings,
isLoading,
error,
} = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Fetch available roles for default user role dropdown
const { data: roles, isLoading: rolesLoading } = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
const newFormData = {
updateInterval: settings.update_interval || 60,
autoUpdate: settings.auto_update || false,
signupEnabled: settings.signup_enabled === true ? true : false,
defaultUserRole: settings.default_user_role || "user",
};
setFormData(newFormData);
setIsDirty(false);
}
}, [settings]);
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (data) => {
return settingsAPI.update(data).then((res) => res.data);
},
onSuccess: () => {
queryClient.invalidateQueries(["settings"]);
setIsDirty(false);
setErrors({});
},
onError: (error) => {
if (error.response?.data?.errors) {
setErrors(
error.response.data.errors.reduce((acc, err) => {
acc[err.path] = err.msg;
return acc;
}, {}),
);
} else {
setErrors({
general: error.response?.data?.error || "Failed to update settings",
});
}
},
});
// Normalize update interval to safe presets
const normalizeInterval = (minutes) => {
let m = parseInt(minutes, 10);
if (Number.isNaN(m)) return 60;
if (m < 5) m = 5;
if (m > 1440) m = 1440;
// If less than 60 minutes, keep within 5-59 and step of 5
if (m < 60) {
return Math.min(59, Math.max(5, Math.round(m / 5) * 5));
}
// 60 or more: only allow exact hour multiples (60, 120, 180, 360, 720, 1440)
const allowed = [60, 120, 180, 360, 720, 1440];
// Snap to nearest allowed value
let nearest = allowed[0];
let bestDiff = Math.abs(m - nearest);
for (const a of allowed) {
const d = Math.abs(m - a);
if (d < bestDiff) {
bestDiff = d;
nearest = a;
}
}
return nearest;
};
const handleInputChange = (field, value) => {
setFormData((prev) => {
const newData = {
...prev,
[field]: field === "updateInterval" ? normalizeInterval(value) : value,
};
return newData;
});
setIsDirty(true);
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const validateForm = () => {
const newErrors = {};
if (
!formData.updateInterval ||
formData.updateInterval < 5 ||
formData.updateInterval > 1440
) {
newErrors.updateInterval =
"Update interval must be between 5 and 1440 minutes";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (validateForm()) {
updateSettingsMutation.mutate(formData);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error loading settings
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.response?.data?.error || "Failed to load settings"}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{errors.general && (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">
{errors.general}
</p>
</div>
</div>
</div>
)}
<form className="space-y-6">
{/* Update Interval */}
<div>
<label
htmlFor={updateIntervalId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Agent Update Interval (minutes)
</label>
{/* Numeric input (concise width) */}
<div className="flex items-center gap-2">
<input
id={updateIntervalId}
type="number"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
handleInputChange(
"updateInterval",
Math.min(1440, Math.max(5, val)),
);
} else {
handleInputChange("updateInterval", 60);
}
}}
className={`w-28 border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.updateInterval
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
placeholder="60"
/>
</div>
{/* Quick presets */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{[5, 10, 15, 30, 45, 60, 120, 180, 360, 720, 1440].map((m) => (
<button
key={m}
type="button"
onClick={() => handleInputChange("updateInterval", m)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border ${
formData.updateInterval === m
? "bg-primary-600 text-white border-primary-600"
: "bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600"
}`}
aria-label={`Set ${m} minutes`}
>
{m % 60 === 0 ? `${m / 60}h` : `${m}m`}
</button>
))}
</div>
{/* Range slider */}
<div className="mt-4">
<input
type="range"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) => {
const raw = parseInt(e.target.value, 10);
handleInputChange("updateInterval", normalizeInterval(raw));
}}
className="w-auto accent-primary-600"
aria-label="Update interval slider"
style={{ width: "fit-content", minWidth: "500px" }}
/>
</div>
{errors.updateInterval && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.updateInterval}
</p>
)}
{/* Helper text */}
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<span className="font-medium">Effective cadence:</span> {(() => {
const mins = parseInt(formData.updateInterval, 10) || 60;
if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"}`;
const hrs = Math.floor(mins / 60);
const rem = mins % 60;
return `${hrs} hour${hrs === 1 ? "" : "s"}${rem ? ` ${rem} min` : ""}`;
})()}
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
This affects new installations and will update existing ones when
they next reach out.
</p>
</div>
{/* Auto-Update Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
id={autoUpdateId}
type="checkbox"
checked={formData.autoUpdate}
onChange={(e) =>
handleInputChange("autoUpdate", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
<label htmlFor={autoUpdateId}>
Enable Automatic Agent Updates
</label>
</div>
</label>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, agents will automatically update themselves when a
newer version is available during their regular update cycle.
</p>
</div>
{/* User Signup Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
id={signupEnabledId}
type="checkbox"
checked={formData.signupEnabled}
onChange={(e) =>
handleInputChange("signupEnabled", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
<label htmlFor={signupEnabledId}>
Enable User Self-Registration
</label>
</div>
</label>
{/* Default User Role Dropdown */}
{formData.signupEnabled && (
<div className="mt-3 ml-6">
<label
htmlFor={defaultRoleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Default Role for New Users
</label>
<select
id={defaultRoleId}
value={formData.defaultUserRole}
onChange={(e) =>
handleInputChange("defaultUserRole", e.target.value)
}
className="w-full max-w-xs 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"
disabled={rolesLoading}
>
{rolesLoading ? (
<option>Loading roles...</option>
) : roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() + role.role.slice(1)}
</option>
))
) : (
<option value="user">User</option>
)}
</select>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
New users will be assigned this role when they register.
</p>
</div>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, users can create their own accounts through the signup
page. When disabled, only administrators can create user accounts.
</p>
</div>
{/* Security Notice */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
Security Notice
</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
When enabling user self-registration, exercise caution on
internal networks. Consider restricting access to trusted
networks only and ensure proper role assignments to prevent
unauthorized access to sensitive systems.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">
Settings saved successfully!
</p>
</div>
</div>
</div>
)}
</form>
</div>
);
};
export default AgentUpdatesTab;

View File

@@ -0,0 +1,305 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle, Save, Server, Shield } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { settingsAPI } from "../../utils/api";
const ProtocolUrlTab = () => {
const protocolId = useId();
const hostId = useId();
const portId = useId();
const [formData, setFormData] = useState({
serverProtocol: "http",
serverHost: "localhost",
serverPort: 3001,
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
const queryClient = useQueryClient();
// Fetch current settings
const {
data: settings,
isLoading,
error,
} = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
const newFormData = {
serverProtocol: settings.server_protocol || "http",
serverHost: settings.server_host || "localhost",
serverPort: settings.server_port || 3001,
};
setFormData(newFormData);
setIsDirty(false);
}
}, [settings]);
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (data) => {
return settingsAPI.update(data).then((res) => res.data);
},
onSuccess: () => {
queryClient.invalidateQueries(["settings"]);
setIsDirty(false);
setErrors({});
},
onError: (error) => {
if (error.response?.data?.errors) {
setErrors(
error.response.data.errors.reduce((acc, err) => {
acc[err.path] = err.msg;
return acc;
}, {}),
);
} else {
setErrors({
general: error.response?.data?.error || "Failed to update settings",
});
}
},
});
const handleInputChange = (field, value) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
setIsDirty(true);
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.serverHost.trim()) {
newErrors.serverHost = "Server host is required";
}
if (
!formData.serverPort ||
formData.serverPort < 1 ||
formData.serverPort > 65535
) {
newErrors.serverPort = "Port must be between 1 and 65535";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (validateForm()) {
updateSettingsMutation.mutate(formData);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error loading settings
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.response?.data?.error || "Failed to load settings"}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{errors.general && (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">
{errors.general}
</p>
</div>
</div>
</div>
)}
<form className="space-y-6">
<div className="flex items-center mb-6">
<Server className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Server Configuration
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label
htmlFor={protocolId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Protocol
</label>
<select
id={protocolId}
value={formData.serverProtocol}
onChange={(e) =>
handleInputChange("serverProtocol", e.target.value)
}
className="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"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div>
<label
htmlFor={hostId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Host *
</label>
<input
id={hostId}
type="text"
value={formData.serverHost}
onChange={(e) => handleInputChange("serverHost", e.target.value)}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.serverHost
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
placeholder="example.com"
/>
{errors.serverHost && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.serverHost}
</p>
)}
</div>
<div>
<label
htmlFor={portId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Port *
</label>
<input
id={portId}
type="number"
value={formData.serverPort}
onChange={(e) =>
handleInputChange("serverPort", parseInt(e.target.value, 10))
}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.serverPort
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
min="1"
max="65535"
/>
{errors.serverPort && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.serverPort}
</p>
)}
</div>
</div>
<div className="mt-4 p-4 bg-secondary-50 dark:bg-secondary-700 rounded-md">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
Server URL
</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 font-mono">
{formData.serverProtocol}://{formData.serverHost}:
{formData.serverPort}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
This URL will be used in installation scripts and agent
communications.
</p>
</div>
{/* Security Notice */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
Security Notice
</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
Changing these settings will affect all installation scripts and
agent communications. Make sure the server URL is accessible
from your client networks.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">
Settings saved successfully!
</p>
</div>
</div>
</div>
)}
</form>
</div>
);
};
export default ProtocolUrlTab;

View File

@@ -0,0 +1,570 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
BarChart3,
CheckCircle,
Download,
Edit,
Package,
Plus,
RefreshCw,
Save,
Server,
Settings,
Shield,
Trash2,
Users,
X,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { permissionsAPI } from "../../utils/api";
const RolesTab = () => {
const [editingRole, setEditingRole] = useState(null);
const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient();
const { refreshPermissions } = useAuth();
// Listen for the header button event to open add modal
useEffect(() => {
const handleOpenAddModal = () => setShowAddModal(true);
window.addEventListener("openAddRoleModal", handleOpenAddModal);
return () =>
window.removeEventListener("openAddRoleModal", handleOpenAddModal);
}, []);
// Fetch all role permissions
const {
data: roles,
isLoading,
error,
} = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update role permissions mutation
const updateRoleMutation = useMutation({
mutationFn: ({ role, permissions }) =>
permissionsAPI.updateRole(role, permissions),
onSuccess: () => {
queryClient.invalidateQueries(["rolePermissions"]);
setEditingRole(null);
// Refresh user permissions to apply changes immediately
refreshPermissions();
},
});
// Delete role mutation
const deleteRoleMutation = useMutation({
mutationFn: (role) => permissionsAPI.deleteRole(role),
onSuccess: () => {
queryClient.invalidateQueries(["rolePermissions"]);
},
});
const handleSavePermissions = async (role, permissions) => {
try {
await updateRoleMutation.mutateAsync({ role, permissions });
} catch (error) {
console.error("Failed to update permissions:", error);
}
};
const handleDeleteRole = async (role) => {
if (
window.confirm(
`Are you sure you want to delete the "${role}" role? This action cannot be undone.`,
)
) {
try {
await deleteRoleMutation.mutateAsync(role);
} catch (error) {
console.error("Failed to delete role:", error);
}
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading permissions
</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Roles Matrix Table */}
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Permission
</th>
{roles &&
Array.isArray(roles) &&
roles.map((r) => (
<th
key={r.role}
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
>
<div className="flex items-center gap-2">
<span className="capitalize">
{r.role.replace(/_/g, " ")}
</span>
<button
type="button"
onClick={() => setEditingRole(r.role)}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-400 dark:hover:text-secondary-200"
title="Edit role permissions"
>
<Edit className="h-4 w-4" />
</button>
</div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{roles &&
Array.isArray(roles) &&
roles.length > 0 &&
Object.keys(roles[0])
.filter((k) => k.startsWith("can_"))
.map((permKey) => (
<tr
key={permKey}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 whitespace-nowrap">
{permKey
.replace(/^can_/, "")
.split("_")
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(" ")}
</td>
{roles.map((r) => (
<td
key={`${r.role}-${permKey}`}
className="px-6 py-3 whitespace-nowrap"
>
{r[permKey] ? (
<div className="flex items-center text-green-600">
<CheckCircle className="h-4 w-4" />
</div>
) : (
<div className="flex items-center text-red-600">
<X className="h-4 w-4" />
</div>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Inline editor for selected role */}
{editingRole && roles && Array.isArray(roles) && (
<div className="space-y-4">
{roles
.filter((r) => r.role === editingRole)
.map((r) => (
<RolePermissionsCard
key={`editor-${r.role}`}
role={r}
isEditing={true}
onEdit={() => {}}
onCancel={() => setEditingRole(null)}
onSave={handleSavePermissions}
onDelete={handleDeleteRole}
/>
))}
</div>
)}
{/* Add Role Modal */}
<AddRoleModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={() => {
queryClient.invalidateQueries(["rolePermissions"]);
setShowAddModal(false);
}}
/>
</div>
);
};
// Role Permissions Card Component
const RolePermissionsCard = ({
role,
isEditing,
onEdit,
onCancel,
onSave,
onDelete,
}) => {
const [permissions, setPermissions] = useState(role);
// Sync permissions state with role prop when it changes
useEffect(() => {
setPermissions(role);
}, [role]);
const permissionFields = [
{
key: "can_view_dashboard",
label: "View Dashboard",
icon: BarChart3,
description: "Access to the main dashboard",
},
{
key: "can_view_hosts",
label: "View Hosts",
icon: Server,
description: "See host information and status",
},
{
key: "can_manage_hosts",
label: "Manage Hosts",
icon: Edit,
description: "Add, edit, and delete hosts",
},
{
key: "can_view_packages",
label: "View Packages",
icon: Package,
description: "See package information",
},
{
key: "can_manage_packages",
label: "Manage Packages",
icon: Settings,
description: "Edit package details",
},
{
key: "can_view_users",
label: "View Users",
icon: Users,
description: "See user list and details",
},
{
key: "can_manage_users",
label: "Manage Users",
icon: Shield,
description: "Add, edit, and delete users",
},
{
key: "can_view_reports",
label: "View Reports",
icon: BarChart3,
description: "Access to reports and analytics",
},
{
key: "can_export_data",
label: "Export Data",
icon: Download,
description: "Download data and reports",
},
{
key: "can_manage_settings",
label: "Manage Settings",
icon: Settings,
description: "System configuration access",
},
];
const handlePermissionChange = (key, value) => {
setPermissions((prev) => ({
...prev,
[key]: value,
}));
};
const handleSave = () => {
onSave(role.role, permissions);
};
const isBuiltInRole = role.role === "admin" || role.role === "user";
return (
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Shield className="h-5 w-5 text-primary-600 mr-3" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">
{role.role}
</h3>
{isBuiltInRole && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
Built-in Role
</span>
)}
</div>
<div className="flex items-center space-x-2">
{isEditing ? (
<>
<button
type="button"
onClick={handleSave}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
>
<Save className="h-4 w-4 mr-1" />
Save
</button>
<button
type="button"
onClick={onCancel}
className="inline-flex items-center px-3 py-1 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
<X className="h-4 w-4 mr-1" />
Cancel
</button>
{!isBuiltInRole && (
<button
type="button"
onClick={() => onDelete(role.role)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
)}
</>
) : (
<>
<button
type="button"
onClick={onEdit}
disabled={isBuiltInRole}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
{!isBuiltInRole && (
<button
type="button"
onClick={() => onDelete(role.role)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
)}
</>
)}
</div>
</div>
</div>
<div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{permissionFields.map((field) => {
const Icon = field.icon;
const isChecked = permissions[field.key];
return (
<div key={field.key} className="flex items-start">
<div className="flex items-center h-5">
<input
id={`${role.role}-${field.key}`}
type="checkbox"
checked={isChecked}
onChange={(e) =>
handlePermissionChange(field.key, e.target.checked)
}
disabled={
!isEditing ||
(isBuiltInRole && field.key === "can_manage_users")
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
/>
</div>
<div className="ml-3">
<div className="flex items-center">
<Icon className="h-4 w-4 text-secondary-400 mr-2" />
<label
htmlFor={`${role.role}-${field.key}`}
className="text-sm font-medium text-secondary-900 dark:text-white"
>
{field.label}
</label>
</div>
<p className="text-xs text-secondary-500 mt-1">
{field.description}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
// Add Role Modal Component
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
const roleNameInputId = useId();
const [formData, setFormData] = useState({
role: "",
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: false,
can_view_packages: true,
can_manage_packages: false,
can_view_users: false,
can_manage_users: false,
can_view_reports: true,
can_export_data: false,
can_manage_settings: false,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
await permissionsAPI.updateRole(formData.role, formData);
onSuccess();
} catch (err) {
setError(err.response?.data?.error || "Failed to create role");
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === "checkbox" ? checked : value,
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Add New Role
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={roleNameInputId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Role Name
</label>
<input
id={roleNameInputId}
type="text"
name="role"
required
value={formData.role}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
placeholder="e.g., host_manager, readonly"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Use lowercase with underscores (e.g., host_manager)
</p>
</div>
<div className="space-y-3">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">
Permissions
</h4>
{[
{ key: "can_view_dashboard", label: "View Dashboard" },
{ key: "can_view_hosts", label: "View Hosts" },
{ key: "can_manage_hosts", label: "Manage Hosts" },
{ key: "can_view_packages", label: "View Packages" },
{ key: "can_manage_packages", label: "Manage Packages" },
{ key: "can_view_users", label: "View Users" },
{ key: "can_manage_users", label: "Manage Users" },
{ key: "can_view_reports", label: "View Reports" },
{ key: "can_export_data", label: "Export Data" },
{ key: "can_manage_settings", label: "Manage Settings" },
].map((permission) => (
<div key={permission.key} className="flex items-center">
<input
id={`add-role-${permission.key}`}
type="checkbox"
name={permission.key}
checked={formData[permission.key]}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label
htmlFor={`add-role-${permission.key}`}
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"
>
{permission.label}
</label>
</div>
))}
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? "Creating..." : "Create Role"}
</button>
</div>
</form>
</div>
</div>
);
};
export default RolesTab;

View File

@@ -0,0 +1,897 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Calendar,
CheckCircle,
Edit,
Key,
Mail,
Plus,
Shield,
Trash2,
User,
XCircle,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { adminUsersAPI, permissionsAPI } from "../../utils/api";
const UsersTab = () => {
const [showAddModal, setShowAddModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [resetPasswordUser, setResetPasswordUser] = useState(null);
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
// Listen for the header button event to open add modal
useEffect(() => {
const handleOpenAddModal = () => setShowAddModal(true);
window.addEventListener("openAddUserModal", handleOpenAddModal);
return () =>
window.removeEventListener("openAddUserModal", handleOpenAddModal);
}, []);
// Fetch users
const {
data: users,
isLoading,
error,
} = useQuery({
queryKey: ["users"],
queryFn: () => adminUsersAPI.list().then((res) => res.data),
});
// Fetch available roles
const { data: roles } = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Delete user mutation
const deleteUserMutation = useMutation({
mutationFn: adminUsersAPI.delete,
onSuccess: () => {
queryClient.invalidateQueries(["users"]);
},
});
// Update user mutation
const updateUserMutation = useMutation({
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(["users"]);
setEditingUser(null);
},
});
// Reset password mutation
const resetPasswordMutation = useMutation({
mutationFn: ({ userId, newPassword }) =>
adminUsersAPI.resetPassword(userId, newPassword),
onSuccess: () => {
queryClient.invalidateQueries(["users"]);
setResetPasswordUser(null);
},
});
const handleDeleteUser = async (userId, username) => {
if (
window.confirm(
`Are you sure you want to delete user "${username}"? This action cannot be undone.`,
)
) {
try {
await deleteUserMutation.mutateAsync(userId);
} catch (error) {
console.error("Failed to delete user:", error);
}
}
};
const handleUserCreated = () => {
queryClient.invalidateQueries(["users"]);
setShowAddModal(false);
};
const handleEditUser = (user) => {
setEditingUser(user);
};
const handleResetPassword = (user) => {
setResetPasswordUser(user);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<XCircle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading users
</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Users Table */}
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{users && Array.isArray(users) && users.length > 0 ? (
users.map((user) => (
<tr
key={user.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-primary-100 flex items-center justify-center">
<User className="h-5 w-5 text-primary-600" />
</div>
</div>
<div className="ml-4">
<div className="flex items-center">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{user.username}
</div>
{user.id === currentUser?.id && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
You
</span>
)}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
<Mail className="h-4 w-4 mr-2" />
{user.email}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.role === "admin"
? "bg-primary-100 text-primary-800"
: user.role === "host_manager"
? "bg-green-100 text-green-800"
: user.role === "readonly"
? "bg-yellow-100 text-yellow-800"
: "bg-secondary-100 text-secondary-800"
}`}
>
<Shield className="h-3 w-3 mr-1" />
{user.role.charAt(0).toUpperCase() +
user.role.slice(1).replace("_", " ")}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{user.is_active ? (
<div className="flex items-center text-green-600">
<CheckCircle className="h-4 w-4 mr-1" />
<span className="text-sm">Active</span>
</div>
) : (
<div className="flex items-center text-red-600">
<XCircle className="h-4 w-4 mr-1" />
<span className="text-sm">Inactive</span>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
<Calendar className="h-4 w-4 mr-2" />
{new Date(user.created_at).toLocaleDateString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
{user.last_login ? (
new Date(user.last_login).toLocaleDateString()
) : (
<span className="text-secondary-400">Never</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<button
type="button"
onClick={() => handleEditUser(user)}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
title="Edit user"
>
<Edit className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleResetPassword(user)}
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
title={
!user.is_active
? "Cannot reset password for inactive user"
: "Reset password"
}
disabled={!user.is_active}
>
<Key className="h-4 w-4" />
</button>
<button
type="button"
onClick={() =>
handleDeleteUser(user.id, user.username)
}
className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed"
title={
user.id === currentUser?.id
? "Cannot delete your own account"
: user.role === "admin" &&
users.filter((u) => u.role === "admin")
.length === 1
? "Cannot delete the last admin user"
: "Delete user"
}
disabled={
user.id === currentUser?.id ||
(user.role === "admin" &&
users.filter((u) => u.role === "admin").length ===
1)
}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan="7" className="px-6 py-12 text-center">
<User className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
No users found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Click "Add User" to create the first user
</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Add User Modal */}
<AddUserModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onUserCreated={handleUserCreated}
roles={roles}
/>
{/* Edit User Modal */}
{editingUser && (
<EditUserModal
user={editingUser}
isOpen={!!editingUser}
onClose={() => setEditingUser(null)}
onUserUpdated={() => updateUserMutation.mutate()}
roles={roles}
/>
)}
{/* Reset Password Modal */}
{resetPasswordUser && (
<ResetPasswordModal
user={resetPasswordUser}
isOpen={!!resetPasswordUser}
onClose={() => setResetPasswordUser(null)}
onPasswordReset={resetPasswordMutation.mutate}
isLoading={resetPasswordMutation.isPending}
/>
)}
</div>
);
};
// Add User Modal Component
const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
const usernameId = useId();
const emailId = useId();
const firstNameId = useId();
const lastNameId = useId();
const passwordId = useId();
const roleId = useId();
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
first_name: "",
last_name: "",
role: "user",
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
// Only send role if roles are available from API
const payload = {
username: formData.username,
email: formData.email,
password: formData.password,
};
if (roles && Array.isArray(roles) && roles.length > 0) {
payload.role = formData.role;
}
await adminUsersAPI.create(payload);
onUserCreated();
} catch (err) {
setError(err.response?.data?.error || "Failed to create user");
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Add New User
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={usernameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Username
</label>
<input
id={usernameId}
type="text"
name="username"
required
value={formData.username}
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"
/>
</div>
<div>
<label
htmlFor={emailId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Email
</label>
<input
id={emailId}
type="email"
name="email"
required
value={formData.email}
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor={firstNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
First Name
</label>
<input
id={firstNameId}
type="text"
name="first_name"
value={formData.first_name}
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"
/>
</div>
<div>
<label
htmlFor={lastNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Last Name
</label>
<input
id={lastNameId}
type="text"
name="last_name"
value={formData.last_name}
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"
/>
</div>
</div>
<div>
<label
htmlFor={passwordId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Password
</label>
<input
id={passwordId}
type="password"
name="password"
required
minLength={6}
value={formData.password}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Minimum 6 characters
</p>
</div>
<div>
<label
htmlFor={roleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Role
</label>
<select
id={roleId}
name="role"
value={formData.role}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
{roles && Array.isArray(roles) && roles.length > 0 ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() +
role.role.slice(1).replace("_", " ")}
</option>
))
) : (
<>
<option value="user">User</option>
<option value="admin">Admin</option>
</>
)}
</select>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? "Creating..." : "Create User"}
</button>
</div>
</form>
</div>
</div>
);
};
// Edit User Modal Component
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
const editUsernameId = useId();
const editEmailId = useId();
const editFirstNameId = useId();
const editLastNameId = useId();
const editRoleId = useId();
const editActiveId = useId();
const [formData, setFormData] = useState({
username: user?.username || "",
email: user?.email || "",
first_name: user?.first_name || "",
last_name: user?.last_name || "",
role: user?.role || "user",
is_active: user?.is_active ?? true,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
await adminUsersAPI.update(user.id, formData);
onUserUpdated();
} catch (err) {
setError(err.response?.data?.error || "Failed to update user");
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === "checkbox" ? checked : value,
});
};
if (!isOpen || !user) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Edit User
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={editUsernameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Username
</label>
<input
id={editUsernameId}
type="text"
name="username"
required
value={formData.username}
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"
/>
</div>
<div>
<label
htmlFor={editEmailId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Email
</label>
<input
id={editEmailId}
type="email"
name="email"
required
value={formData.email}
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor={editFirstNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
First Name
</label>
<input
id={editFirstNameId}
type="text"
name="first_name"
value={formData.first_name}
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"
/>
</div>
<div>
<label
htmlFor={editLastNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Last Name
</label>
<input
id={editLastNameId}
type="text"
name="last_name"
value={formData.last_name}
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"
/>
</div>
</div>
<div>
<label
htmlFor={editRoleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Role
</label>
<select
id={editRoleId}
name="role"
value={formData.role}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
{roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() +
role.role.slice(1).replace("_", " ")}
</option>
))
) : (
<>
<option value="user">User</option>
<option value="admin">Admin</option>
</>
)}
</select>
</div>
<div className="flex items-center">
<input
id={editActiveId}
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label
htmlFor={editActiveId}
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"
>
Active user
</label>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? "Updating..." : "Update User"}
</button>
</div>
</form>
</div>
</div>
);
};
// Reset Password Modal Component
const ResetPasswordModal = ({
user,
isOpen,
onClose,
onPasswordReset,
isLoading,
}) => {
const newPasswordId = useId();
const confirmPasswordId = useId();
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
// Validate passwords
if (newPassword.length < 6) {
setError("Password must be at least 6 characters long");
return;
}
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
return;
}
try {
await onPasswordReset({ userId: user.id, newPassword });
// Reset form on success
setNewPassword("");
setConfirmPassword("");
} catch (err) {
setError(err.response?.data?.error || "Failed to reset password");
}
};
const handleClose = () => {
setNewPassword("");
setConfirmPassword("");
setError("");
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Reset Password for {user.username}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={newPasswordId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
New Password
</label>
<input
id={newPasswordId}
type="password"
required
minLength={6}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
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="Enter new password (min 6 characters)"
/>
</div>
<div>
<label
htmlFor={confirmPasswordId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Confirm Password
</label>
<input
id={confirmPasswordId}
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
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="Confirm new password"
/>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-md p-3">
<div className="flex">
<div className="flex-shrink-0">
<Key className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Password Reset Warning
</h3>
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>
This will immediately change the user's password. The user
will need to use the new password to login.
</p>
</div>
</div>
</div>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center"
>
{isLoading && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
)}
{isLoading ? "Resetting..." : "Reset Password"}
</button>
</div>
</form>
</div>
</div>
);
};
export default UsersTab;

View File

@@ -0,0 +1,573 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle,
Clock,
Code,
Download,
Save,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { settingsAPI, versionAPI } from "../../utils/api";
const VersionUpdateTab = () => {
const repoPublicId = useId();
const repoPrivateId = useId();
const useCustomSshKeyId = useId();
const githubRepoUrlId = useId();
const sshKeyPathId = useId();
const [formData, setFormData] = useState({
githubRepoUrl: "git@github.com:9technologygroup/patchmon.net.git",
repositoryType: "public",
sshKeyPath: "",
useCustomSshKey: false,
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
// Version checking state
const [versionInfo, setVersionInfo] = useState({
currentVersion: null,
latestVersion: null,
isUpdateAvailable: false,
checking: false,
error: null,
});
const [sshTestResult, setSshTestResult] = useState({
testing: false,
success: null,
message: null,
error: null,
});
const queryClient = useQueryClient();
// Fetch current settings
const {
data: settings,
isLoading,
error,
} = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
const newFormData = {
githubRepoUrl:
settings.github_repo_url ||
"git@github.com:9technologygroup/patchmon.net.git",
repositoryType: settings.repository_type || "public",
sshKeyPath: settings.ssh_key_path || "",
useCustomSshKey: !!settings.ssh_key_path,
};
setFormData(newFormData);
setIsDirty(false);
}
}, [settings]);
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (data) => {
return settingsAPI.update(data).then((res) => res.data);
},
onSuccess: () => {
queryClient.invalidateQueries(["settings"]);
setIsDirty(false);
setErrors({});
},
onError: (error) => {
if (error.response?.data?.errors) {
setErrors(
error.response.data.errors.reduce((acc, err) => {
acc[err.path] = err.msg;
return acc;
}, {}),
);
} else {
setErrors({
general: error.response?.data?.error || "Failed to update settings",
});
}
},
});
// Load current version on component mount
useEffect(() => {
const loadCurrentVersion = async () => {
try {
const response = await versionAPI.getCurrent();
const data = response.data;
setVersionInfo((prev) => ({
...prev,
currentVersion: data.version,
}));
} catch (error) {
console.error("Error loading current version:", error);
}
};
loadCurrentVersion();
}, []);
// Version checking functions
const checkForUpdates = async () => {
setVersionInfo((prev) => ({ ...prev, checking: true, error: null }));
try {
const response = await versionAPI.checkUpdates();
const data = response.data;
setVersionInfo({
currentVersion: data.currentVersion,
latestVersion: data.latestVersion,
isUpdateAvailable: data.isUpdateAvailable,
last_update_check: data.last_update_check,
checking: false,
error: null,
});
} catch (error) {
console.error("Version check error:", error);
setVersionInfo((prev) => ({
...prev,
checking: false,
error: error.response?.data?.error || "Failed to check for updates",
}));
}
};
const testSshKey = async () => {
if (!formData.sshKeyPath || !formData.githubRepoUrl) {
setSshTestResult({
testing: false,
success: false,
message: null,
error: "Please enter both SSH key path and GitHub repository URL",
});
return;
}
setSshTestResult({
testing: true,
success: null,
message: null,
error: null,
});
try {
const response = await versionAPI.testSshKey({
sshKeyPath: formData.sshKeyPath,
githubRepoUrl: formData.githubRepoUrl,
});
setSshTestResult({
testing: false,
success: true,
message: response.data.message,
error: null,
});
} catch (error) {
console.error("SSH key test error:", error);
setSshTestResult({
testing: false,
success: false,
message: null,
error: error.response?.data?.error || "Failed to test SSH key",
});
}
};
const handleInputChange = (field, value) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
setIsDirty(true);
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const handleSave = () => {
// Only include sshKeyPath if the toggle is enabled
const dataToSubmit = { ...formData };
if (!dataToSubmit.useCustomSshKey) {
dataToSubmit.sshKeyPath = "";
}
// Remove the frontend-only field
delete dataToSubmit.useCustomSshKey;
updateSettingsMutation.mutate(dataToSubmit);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error loading settings
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.response?.data?.error || "Failed to load settings"}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{errors.general && (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">
{errors.general}
</p>
</div>
</div>
</div>
)}
<div className="flex items-center mb-6">
<Code className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Server Version Management
</h2>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Version Check Configuration
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
Configure automatic version checking against your GitHub repository to
notify users of available updates.
</p>
<div className="space-y-4">
<fieldset>
<legend className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Repository Type
</legend>
<div className="space-y-2">
<div className="flex items-center">
<input
type="radio"
id={repoPublicId}
name="repositoryType"
value="public"
checked={formData.repositoryType === "public"}
onChange={(e) =>
handleInputChange("repositoryType", e.target.value)
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
/>
<label
htmlFor={repoPublicId}
className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
>
Public Repository (uses GitHub API - no authentication
required)
</label>
</div>
<div className="flex items-center">
<input
type="radio"
id={repoPrivateId}
name="repositoryType"
value="private"
checked={formData.repositoryType === "private"}
onChange={(e) =>
handleInputChange("repositoryType", e.target.value)
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
/>
<label
htmlFor={repoPrivateId}
className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
>
Private Repository (uses SSH with deploy key)
</label>
</div>
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Choose whether your repository is public or private to determine
the appropriate access method.
</p>
</fieldset>
<div>
<label
htmlFor={githubRepoUrlId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
GitHub Repository URL
</label>
<input
id={githubRepoUrlId}
type="text"
value={formData.githubRepoUrl || ""}
onChange={(e) =>
handleInputChange("githubRepoUrl", e.target.value)
}
className="w-full border 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 font-mono text-sm"
placeholder="git@github.com:username/repository.git"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
SSH or HTTPS URL to your GitHub repository
</p>
</div>
{formData.repositoryType === "private" && (
<div>
<div className="flex items-center gap-3 mb-3">
<input
type="checkbox"
id={useCustomSshKeyId}
checked={formData.useCustomSshKey}
onChange={(e) => {
const checked = e.target.checked;
handleInputChange("useCustomSshKey", checked);
if (!checked) {
handleInputChange("sshKeyPath", "");
}
}}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label
htmlFor={useCustomSshKeyId}
className="text-sm font-medium text-secondary-700 dark:text-secondary-200"
>
Set custom SSH key path
</label>
</div>
{formData.useCustomSshKey && (
<div>
<label
htmlFor={sshKeyPathId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
SSH Key Path
</label>
<input
id={sshKeyPathId}
type="text"
value={formData.sshKeyPath || ""}
onChange={(e) =>
handleInputChange("sshKeyPath", e.target.value)
}
className="w-full border 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 font-mono text-sm"
placeholder="/root/.ssh/id_ed25519"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path to your SSH deploy key. If not set, will auto-detect
from common locations.
</p>
<div className="mt-3">
<button
type="button"
onClick={testSshKey}
disabled={
sshTestResult.testing ||
!formData.sshKeyPath ||
!formData.githubRepoUrl
}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{sshTestResult.testing ? "Testing..." : "Test SSH Key"}
</button>
{sshTestResult.success && (
<div className="mt-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md">
<div className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
<p className="text-sm text-green-800 dark:text-green-200">
{sshTestResult.message}
</p>
</div>
</div>
)}
{sshTestResult.error && (
<div className="mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div className="flex items-center">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mr-2" />
<p className="text-sm text-red-800 dark:text-red-200">
{sshTestResult.error}
</p>
</div>
</div>
)}
</div>
</div>
)}
{!formData.useCustomSshKey && (
<p className="text-xs text-secondary-500 dark:text-secondary-400">
Using auto-detection for SSH key location
</p>
)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Current Version
</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.currentVersion}
</span>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Latest Version
</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.checking ? (
<span className="text-blue-600 dark:text-blue-400">
Checking...
</span>
) : versionInfo.latestVersion ? (
<span
className={
versionInfo.isUpdateAvailable
? "text-orange-600 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}
>
{versionInfo.latestVersion}
{versionInfo.isUpdateAvailable && " (Update Available!)"}
</span>
) : (
<span className="text-secondary-500 dark:text-secondary-400">
Not checked
</span>
)}
</span>
</div>
</div>
{/* Last Checked Time */}
{versionInfo.last_update_check && (
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Last Checked
</span>
</div>
<span className="text-sm text-secondary-600 dark:text-secondary-400">
{new Date(versionInfo.last_update_check).toLocaleString()}
</span>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
Updates are checked automatically every 24 hours
</p>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={checkForUpdates}
disabled={versionInfo.checking}
className="btn-primary flex items-center gap-2"
>
<Download className="h-4 w-4" />
{versionInfo.checking ? "Checking..." : "Check for Updates"}
</button>
</div>
{/* Save Button for Version Settings */}
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{versionInfo.error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Version Check Failed
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{versionInfo.error}
</p>
{versionInfo.error.includes("private") && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
For private repositories, you may need to configure GitHub
authentication or make the repository public.
</p>
)}
</div>
</div>
</div>
)}
{/* Success Message for Version Settings */}
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">
Settings saved successfully!
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default VersionUpdateTab;

View File

@@ -257,6 +257,7 @@ const Hosts = () => {
const filter = searchParams.get("filter");
const showFiltersParam = searchParams.get("showFilters");
const osFilterParam = searchParams.get("osFilter");
const groupParam = searchParams.get("group");
if (filter === "needsUpdates") {
setShowFilters(true);
@@ -284,6 +285,12 @@ const Hosts = () => {
setOsFilter(osFilterParam);
}
// Handle group filter parameter
if (groupParam) {
setShowFilters(true);
setGroupFilter(groupParam);
}
// Handle add host action from navigation
const action = searchParams.get("action");
if (action === "add") {

View File

@@ -142,7 +142,6 @@ const Profile = () => {
{ id: "profile", name: "Profile Information", icon: User },
{ id: "password", name: "Change Password", icon: Key },
{ id: "tfa", name: "Multi-Factor Authentication", icon: Smartphone },
{ id: "preferences", name: "Preferences", icon: Settings },
];
return (
@@ -338,6 +337,52 @@ const Profile = () => {
</div>
</div>
{/* Theme Settings */}
<div className="border-t border-secondary-200 dark:border-secondary-600 pt-6">
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">
Appearance
</h4>
<div className="max-w-md">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
{isDark ? (
<Moon className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
) : (
<Sun className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
)}
</div>
<div>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{isDark ? "Dark Mode" : "Light Mode"}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-400">
{isDark
? "Switch to light mode"
: "Switch to dark mode"}
</p>
</div>
</div>
<button
type="button"
onClick={toggleTheme}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
isDark ? "bg-primary-600" : "bg-secondary-200"
}`}
role="switch"
aria-checked={isDark}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
isDark ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
@@ -477,62 +522,6 @@ const Profile = () => {
{/* Multi-Factor Authentication Tab */}
{activeTab === "tfa" && <TfaTab />}
{/* Preferences Tab */}
{activeTab === "preferences" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Preferences
</h3>
{/* Theme Settings */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">
Appearance
</h4>
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
{isDark ? (
<Moon className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
) : (
<Sun className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
)}
</div>
<div>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{isDark ? "Dark Mode" : "Light Mode"}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-400">
{isDark
? "Switch to light mode"
: "Switch to dark mode"}
</p>
</div>
</div>
<button
type="button"
onClick={toggleTheme}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
isDark ? "bg-primary-600" : "bg-secondary-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isDark ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,89 @@
import { Code, Settings } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import SettingsLayout from "../../components/SettingsLayout";
import AgentManagementTab from "../../components/settings/AgentManagementTab";
import AgentUpdatesTab from "../../components/settings/AgentUpdatesTab";
const SettingsAgentConfig = () => {
const location = useLocation();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState(() => {
// Set initial tab based on current route
if (location.pathname === "/settings/agent-version") return "management";
return "updates";
});
// Update active tab when route changes
useEffect(() => {
if (location.pathname === "/settings/agent-version") {
setActiveTab("management");
} else if (location.pathname === "/settings/agent-config") {
setActiveTab("updates");
}
}, [location.pathname]);
const tabs = [
{
id: "updates",
name: "Agent Updates",
icon: Settings,
href: "/settings/agent-config",
},
{
id: "management",
name: "Agent Version",
icon: Code,
href: "/settings/agent-version",
},
];
const renderTabContent = () => {
switch (activeTab) {
case "updates":
return <AgentUpdatesTab />;
case "management":
return <AgentManagementTab />;
default:
return <AgentUpdatesTab />;
}
};
return (
<SettingsLayout>
<div className="space-y-6">
{/* Tab Navigation */}
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
type="button"
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
navigate(tab.href);
}}
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === tab.id
? "border-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
}`}
>
<Icon className="h-4 w-4" />
{tab.name}
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="mt-6">{renderTabContent()}</div>
</div>
</SettingsLayout>
);
};
export default SettingsAgentConfig;

View File

@@ -0,0 +1,599 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Edit, Plus, Server, Trash2 } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useNavigate } from "react-router-dom";
import SettingsLayout from "../../components/SettingsLayout";
import { hostGroupsAPI } from "../../utils/api";
const SettingsHostGroups = () => {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [groupToDelete, setGroupToDelete] = useState(null);
const queryClient = useQueryClient();
const navigate = useNavigate();
// Fetch host groups
const {
data: hostGroups,
isLoading,
error,
} = useQuery({
queryKey: ["hostGroups"],
queryFn: () => hostGroupsAPI.list().then((res) => res.data),
});
// Create host group mutation
const createMutation = useMutation({
mutationFn: (data) => hostGroupsAPI.create(data),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowCreateModal(false);
},
onError: (error) => {
console.error("Failed to create host group:", error);
},
});
// Update host group mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowEditModal(false);
setSelectedGroup(null);
},
onError: (error) => {
console.error("Failed to update host group:", error);
},
});
// Delete host group mutation
const deleteMutation = useMutation({
mutationFn: (id) => hostGroupsAPI.delete(id),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowDeleteModal(false);
setGroupToDelete(null);
},
onError: (error) => {
console.error("Failed to delete host group:", error);
},
});
const handleCreate = (data) => {
createMutation.mutate(data);
};
const handleEdit = (group) => {
setSelectedGroup(group);
setShowEditModal(true);
};
const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data });
};
const handleDeleteClick = (group) => {
setGroupToDelete(group);
setShowDeleteModal(true);
};
const handleDeleteConfirm = () => {
deleteMutation.mutate(groupToDelete.id);
};
const handleHostsClick = (groupId) => {
navigate(`/hosts?group=${groupId}`);
};
// Listen for delete modal trigger from edit modal
useEffect(() => {
const handleOpenDeleteModal = (event) => {
setGroupToDelete(event.detail);
setShowDeleteModal(true);
};
window.addEventListener("openDeleteHostGroupModal", handleOpenDeleteModal);
return () =>
window.removeEventListener(
"openDeleteHostGroupModal",
handleOpenDeleteModal,
);
}, []);
return (
<SettingsLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex justify-end items-center">
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
title="Create host group"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
{/* Host Groups Table */}
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Group
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Description
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Color
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Hosts
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{isLoading ? (
<tr>
<td colSpan="5" className="px-6 py-12 text-center">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
</td>
</tr>
) : error ? (
<tr>
<td colSpan="5" className="px-6 py-12 text-center">
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading host groups
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || "Failed to load host groups"}
</p>
</div>
</div>
</div>
</td>
</tr>
) : hostGroups && hostGroups.length > 0 ? (
hostGroups.map((group) => (
<tr
key={group.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="w-3 h-3 rounded-full mr-3"
style={{ backgroundColor: group.color }}
/>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{group.name}
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-secondary-500 dark:text-secondary-300">
{group.description || (
<span className="text-secondary-400 italic">
No description
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="w-6 h-6 rounded border border-secondary-300"
style={{ backgroundColor: group.color }}
/>
<span className="ml-2 text-sm text-secondary-500 dark:text-secondary-300">
{group.color}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
type="button"
onClick={() => handleHostsClick(group.id)}
className="flex items-center text-sm text-secondary-500 dark:text-secondary-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
title={`View hosts in ${group.name}`}
>
<Server className="h-4 w-4 mr-2" />
{group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
type="button"
onClick={() => handleEdit(group)}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
title="Edit group"
>
<Edit className="h-4 w-4" />
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan="5" className="px-6 py-12 text-center">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
No host groups found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Click "Create Group" to create the first host group
</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<CreateHostGroupModal
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
)}
{/* Edit Modal */}
{showEditModal && selectedGroup && (
<EditHostGroupModal
group={selectedGroup}
onClose={() => {
setShowEditModal(false);
setSelectedGroup(null);
}}
onSubmit={handleUpdate}
isLoading={updateMutation.isPending}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && groupToDelete && (
<DeleteHostGroupModal
group={groupToDelete}
onClose={() => {
setShowDeleteModal(false);
setGroupToDelete(null);
}}
onConfirm={handleDeleteConfirm}
isLoading={deleteMutation.isPending}
/>
)}
</div>
</SettingsLayout>
);
};
// Create Host Group Modal
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
const nameId = useId();
const descriptionId = useId();
const colorId = useId();
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Create Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={nameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Name *
</label>
<input
type="text"
id={nameId}
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label
htmlFor={descriptionId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Description
</label>
<textarea
id={descriptionId}
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label
htmlFor={colorId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id={colorId}
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 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="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Creating..." : "Create Group"}
</button>
</div>
</form>
</div>
</div>
);
};
// Edit Host Group Modal
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
const editNameId = useId();
const editDescriptionId = useId();
const editColorId = useId();
const [formData, setFormData] = useState({
name: group.name,
description: group.description || "",
color: group.color || "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Edit Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={editNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Name *
</label>
<input
type="text"
id={editNameId}
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label
htmlFor={editDescriptionId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Description
</label>
<textarea
id={editDescriptionId}
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label
htmlFor={editColorId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id={editColorId}
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 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="#3B82F6"
/>
</div>
</div>
<div className="flex justify-between pt-4">
<button
type="button"
onClick={() => {
onClose();
// Trigger delete modal
window.dispatchEvent(
new CustomEvent("openDeleteHostGroupModal", {
detail: group,
}),
);
}}
className="btn-danger"
disabled={isLoading}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Group
</button>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? "Updating..." : "Update Group"}
</button>
</div>
</div>
</form>
</div>
</div>
);
};
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Host Group
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
This action cannot be undone
</p>
</div>
</div>
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200">
Are you sure you want to delete the host group{" "}
<span className="font-semibold">"{group.name}"</span>?
</p>
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800">
<strong>Note:</strong> This group contains {group._count.hosts}{" "}
host
{group._count.hosts !== 1 ? "s" : ""}. These hosts will be moved
to "No group" after deletion.
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className="btn-danger"
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete Group"}
</button>
</div>
</div>
</div>
);
};
export default SettingsHostGroups;

View File

@@ -0,0 +1,96 @@
import { Code, Server } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import SettingsLayout from "../../components/SettingsLayout";
import ProtocolUrlTab from "../../components/settings/ProtocolUrlTab";
import VersionUpdateTab from "../../components/settings/VersionUpdateTab";
const SettingsServerConfig = () => {
const location = useLocation();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState(() => {
// Set initial tab based on current route
if (location.pathname === "/settings/server-version") return "version";
if (location.pathname === "/settings/server-url") return "protocol";
if (location.pathname === "/settings/server-config/version")
return "version";
return "protocol";
});
// Update active tab when route changes
useEffect(() => {
if (location.pathname === "/settings/server-version") {
setActiveTab("version");
} else if (location.pathname === "/settings/server-url") {
setActiveTab("protocol");
} else if (location.pathname === "/settings/server-config/version") {
setActiveTab("version");
} else if (location.pathname === "/settings/server-config") {
setActiveTab("protocol");
}
}, [location.pathname]);
const tabs = [
{
id: "protocol",
name: "URL Config",
icon: Server,
href: "/settings/server-url",
},
{
id: "version",
name: "Server Version",
icon: Code,
href: "/settings/server-version",
},
];
const renderTabContent = () => {
switch (activeTab) {
case "protocol":
return <ProtocolUrlTab />;
case "version":
return <VersionUpdateTab />;
default:
return <ProtocolUrlTab />;
}
};
return (
<SettingsLayout>
<div className="space-y-6">
{/* Tab Navigation */}
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
type="button"
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
navigate(tab.href);
}}
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === tab.id
? "border-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
}`}
>
<Icon className="h-4 w-4" />
{tab.name}
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="mt-6">{renderTabContent()}</div>
</div>
</SettingsLayout>
);
};
export default SettingsServerConfig;

View File

@@ -0,0 +1,107 @@
import { Plus, Shield, Users } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import SettingsLayout from "../../components/SettingsLayout";
import RolesTab from "../../components/settings/RolesTab";
import UsersTab from "../../components/settings/UsersTab";
const SettingsUsers = () => {
const location = useLocation();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState(() => {
// Set initial tab based on current route
if (location.pathname === "/settings/roles") return "roles";
return "users";
});
const tabs = [
{ id: "users", name: "Users", icon: Users, href: "/settings/users" },
{ id: "roles", name: "Roles", icon: Shield, href: "/settings/roles" },
];
// Update active tab when route changes
useEffect(() => {
if (location.pathname === "/settings/roles") {
setActiveTab("roles");
} else if (location.pathname === "/settings/users") {
setActiveTab("users");
}
}, [location.pathname]);
const renderTabContent = () => {
switch (activeTab) {
case "users":
return <UsersTab />;
case "roles":
return <RolesTab />;
default:
return <UsersTab />;
}
};
return (
<SettingsLayout>
<div className="space-y-6">
{/* Tab Navigation */}
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex items-center justify-between">
<div className="flex space-x-8">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
type="button"
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
navigate(tab.href);
}}
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === tab.id
? "border-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
}`}
>
<Icon className="h-4 w-4" />
{tab.name}
</button>
);
})}
</div>
{activeTab === "users" && (
<button
type="button"
onClick={() =>
window.dispatchEvent(new Event("openAddUserModal"))
}
className="btn-primary flex items-center gap-2"
title="Add user"
>
<Plus className="h-4 w-4" />
Add User
</button>
)}
{activeTab === "roles" && (
<button
type="button"
onClick={() =>
window.dispatchEvent(new Event("openAddRoleModal"))
}
className="btn-primary flex items-center gap-2"
title="Add role"
>
<Plus className="h-4 w-4" />
Add Role
</button>
)}
</nav>
</div>
{/* Tab Content */}
<div className="mt-6">{renderTabContent()}</div>
</div>
</SettingsLayout>
);
};
export default SettingsUsers;