Merge remote-tracking branch 'origin/v1-2-7' into v1-2-7

This commit is contained in:
Muhammad Ibrahim
2025-09-28 15:46:02 +01:00
17 changed files with 276 additions and 165 deletions

View File

@@ -821,7 +821,7 @@ EOF
success "Update sent successfully"
echo "$response" | grep -o '"packagesProcessed":[0-9]*' | cut -d':' -f2 | xargs -I {} info "Processed {} packages"
# Check for auto-update instructions (look specifically in autoUpdate section)
# Check for PatchMon agent update instructions (this updates the agent script, not system packages)
if echo "$response" | grep -q '"autoUpdate":{'; then
local auto_update_section=$(echo "$response" | grep -o '"autoUpdate":{[^}]*}')
local should_update=$(echo "$auto_update_section" | grep -o '"shouldUpdate":true' | cut -d':' -f2)
@@ -830,15 +830,15 @@ EOF
local current_version=$(echo "$auto_update_section" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4)
local update_message=$(echo "$auto_update_section" | grep -o '"message":"[^"]*' | cut -d'"' -f4)
info "Auto-update detected: $update_message"
info "PatchMon agent update detected: $update_message"
info "Current version: $current_version, Latest version: $latest_version"
# Automatically run update-agent command
info "Automatically updating agent to latest version..."
# Automatically run update-agent command to update the PatchMon agent script
info "Automatically updating PatchMon agent to latest version..."
if "$0" update-agent; then
success "Agent auto-update completed successfully"
success "PatchMon agent update completed successfully"
else
warning "Agent auto-update failed, but data was sent successfully"
warning "PatchMon agent update failed, but data was sent successfully"
fi
fi
fi

View File

@@ -158,8 +158,8 @@ else
warning "Initial package data failed, but agent is configured. You can run 'patchmon-agent.sh update' manually."
fi
# Setup crontab for automatic updates
info "⏰ Setting up automatic updates every $UPDATE_INTERVAL minutes..."
# Setup crontab for automatic package status updates
info "⏰ Setting up automatic package status update every $UPDATE_INTERVAL minutes..."
if [[ $UPDATE_INTERVAL -eq 60 ]]; then
# Hourly updates
echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | crontab -
@@ -179,7 +179,7 @@ if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
fi
echo " • Config directory: /etc/patchmon/"
echo " • Credentials file: /etc/patchmon/credentials"
echo "Automatic updates: Every $UPDATE_INTERVAL minutes via crontab"
echo "Status updates: Every $UPDATE_INTERVAL minutes via crontab"
echo " • View logs: tail -f /var/log/patchmon-agent.log"
echo ""
echo "🔧 Manual commands:"

View File

@@ -118,9 +118,21 @@ router.post(
// Create default dashboard preferences for the new admin user
await createDefaultDashboardPreferences(user.id, "admin");
// Generate token for immediate login
const token = generateToken(user.id);
res.status(201).json({
message: "Admin user created successfully",
user: user,
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
first_name: user.first_name,
last_name: user.last_name,
is_active: user.is_active,
},
});
} catch (error) {
console.error("Error creating admin user:", error);

View File

@@ -375,14 +375,21 @@ router.post(
updateData.status = "active";
}
await prisma.hosts.update({
where: { id: host.id },
data: updateData,
});
// Calculate package counts before transaction
const securityCount = packages.filter(
(pkg) => pkg.isSecurityUpdate,
).length;
const updatesCount = packages.filter((pkg) => pkg.needsUpdate).length;
// Process packages in transaction
// Process everything in a single transaction to avoid race conditions
await prisma.$transaction(async (tx) => {
// Clear existing host packages
// Update host data
await tx.hosts.update({
where: { id: host.id },
data: updateData,
});
// Clear existing host packages to avoid duplicates
await tx.host_packages.deleteMany({
where: { host_id: host.id },
});
@@ -423,8 +430,22 @@ router.post(
}
// Create host package relationship
await tx.host_packages.create({
data: {
// Use upsert to handle potential duplicates gracefully
await tx.host_packages.upsert({
where: {
host_id_package_id: {
host_id: host.id,
package_id: pkg.id,
},
},
update: {
current_version: packageData.currentVersion,
available_version: packageData.availableVersion || null,
needs_update: packageData.needsUpdate,
is_security_update: packageData.isSecurityUpdate || false,
last_checked: new Date(),
},
create: {
id: uuidv4(),
host_id: host.id,
package_id: pkg.id,
@@ -493,29 +514,24 @@ router.post(
});
}
}
// Create update history record
await tx.update_history.create({
data: {
id: uuidv4(),
host_id: host.id,
packages_count: updatesCount,
security_count: securityCount,
status: "success",
},
});
});
// Create update history record
const securityCount = packages.filter(
(pkg) => pkg.isSecurityUpdate,
).length;
const updatesCount = packages.filter((pkg) => pkg.needsUpdate).length;
await prisma.update_history.create({
data: {
id: uuidv4(),
host_id: host.id,
packages_count: updatesCount,
security_count: securityCount,
status: "success",
},
});
// Check if auto-update is enabled and if there's a newer agent version available
// Check if agent auto-update is enabled and if there's a newer version available
let autoUpdateResponse = null;
try {
const settings = await prisma.settings.findFirst();
// Check both global auto-update setting AND host-specific auto-update setting
// Check both global agent auto-update setting AND host-specific agent auto-update setting
if (settings?.auto_update && host.auto_update) {
// Get current agent version from the request
const currentAgentVersion = req.body.agentVersion;
@@ -544,8 +560,8 @@ router.post(
}
}
} catch (error) {
console.error("Auto-update check error:", error);
// Don't fail the update if auto-update check fails
console.error("Agent auto-update check error:", error);
// Don't fail the update if agent auto-update check fails
}
const response = {
@@ -555,7 +571,7 @@ router.post(
securityUpdates: securityCount,
};
// Add auto-update response if available
// Add agent auto-update response if available
if (autoUpdateResponse) {
response.autoUpdate = autoUpdateResponse;
}
@@ -1044,7 +1060,7 @@ router.delete(
},
);
// Toggle host auto-update setting
// Toggle agent auto-update setting
router.patch(
"/:hostId/auto-update",
authenticateToken,
@@ -1052,7 +1068,7 @@ router.patch(
[
body("auto_update")
.isBoolean()
.withMessage("Auto-update must be a boolean"),
.withMessage("Agent auto-update setting must be a boolean"),
],
async (req, res) => {
try {
@@ -1073,7 +1089,7 @@ router.patch(
});
res.json({
message: `Host auto-update ${auto_update ? "enabled" : "disabled"} successfully`,
message: `Agent auto-update ${auto_update ? "enabled" : "disabled"} successfully`,
host: {
id: host.id,
friendlyName: host.friendly_name,
@@ -1081,8 +1097,8 @@ router.patch(
},
});
} catch (error) {
console.error("Host auto-update toggle error:", error);
res.status(500).json({ error: "Failed to toggle host auto-update" });
console.error("Agent auto-update toggle error:", error);
res.status(500).json({ error: "Failed to toggle agent auto-update" });
}
},
);

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 { isAuthPhase } from "./constants/authPhases";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { ThemeProvider } from "./contexts/ThemeContext";
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
@@ -20,11 +21,14 @@ import Settings from "./pages/Settings";
import Users from "./pages/Users";
function AppRoutes() {
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth();
const { needsFirstTimeSetup, authPhase, isAuthenticated } = useAuth();
const isAuth = isAuthenticated(); // Call the function to get boolean value
// Show loading while checking if setup is needed
if (checkingSetup) {
// Show loading while checking setup or initialising
if (
isAuthPhase.initialising(authPhase) ||
isAuthPhase.checkingSetup(authPhase)
) {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
<div className="text-center">

View File

@@ -1,9 +1,11 @@
import { AlertCircle, CheckCircle, Shield, UserPlus } from "lucide-react";
import { useId, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const FirstTimeAdminSetup = () => {
const { login } = useAuth();
const { login, setAuthState } = useAuth();
const navigate = useNavigate();
const firstNameId = useId();
const lastNameId = useId();
const usernameId = useId();
@@ -95,10 +97,29 @@ const FirstTimeAdminSetup = () => {
if (response.ok) {
setSuccess(true);
// Auto-login the user after successful setup
setTimeout(() => {
login(formData.username.trim(), formData.password);
}, 2000);
// If the response includes a token, use it to automatically log in
if (data.token && data.user) {
// Set the authentication state immediately
setAuthState(data.token, data.user);
// Navigate to dashboard after successful setup
setTimeout(() => {
navigate("/", { replace: true });
}, 100); // Small delay to ensure auth state is set
} else {
// Fallback to manual login if no token provided
setTimeout(async () => {
try {
await login(formData.username.trim(), formData.password);
} catch (error) {
console.error("Auto-login failed:", error);
setError(
"Account created but auto-login failed. Please login manually.",
);
setSuccess(false);
}
}, 2000);
}
} else {
setError(data.error || "Failed to create admin user");
}
@@ -124,8 +145,8 @@ const FirstTimeAdminSetup = () => {
Admin Account Created!
</h1>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Your admin account has been successfully created. You will be
automatically logged in shortly.
Your admin account has been successfully created and you are now
logged in. Redirecting to the dashboard...
</p>
<div className="flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>

View File

@@ -28,7 +28,7 @@ import {
X,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
import { dashboardAPI, versionAPI } from "../utils/api";
@@ -44,6 +44,7 @@ const Layout = ({ children }) => {
const [_userMenuOpen, setUserMenuOpen] = useState(false);
const [githubStars, setGithubStars] = useState(null);
const location = useLocation();
const navigate = useNavigate();
const {
user,
logout,
@@ -236,11 +237,19 @@ const Layout = ({ children }) => {
const handleAddHost = () => {
// Navigate to hosts page with add modal parameter
window.location.href = "/hosts?action=add";
navigate("/hosts?action=add");
};
// Fetch GitHub stars count
const fetchGitHubStars = useCallback(async () => {
// Skip if already fetched recently
const lastFetch = localStorage.getItem("githubStarsFetchTime");
const now = Date.now();
if (lastFetch && now - parseInt(lastFetch, 15) < 600000) {
// 15 minute cache
return;
}
try {
const response = await fetch(
"https://api.github.com/repos/9technologygroup/patchmon.net",
@@ -248,6 +257,7 @@ const Layout = ({ children }) => {
if (response.ok) {
const data = await response.json();
setGithubStars(data.stargazers_count);
localStorage.setItem("githubStarsFetchTime", now.toString());
}
} catch (error) {
console.error("Failed to fetch GitHub stars:", error);

View File

@@ -0,0 +1,29 @@
/**
* Authentication phases for the centralized auth state machine
*
* Flow: INITIALISING → CHECKING_SETUP → READY
*/
export const AUTH_PHASES = {
INITIALISING: "INITIALISING",
CHECKING_SETUP: "CHECKING_SETUP",
READY: "READY",
};
/**
* Helper functions for auth phase management
*/
export const isAuthPhase = {
initialising: (phase) => phase === AUTH_PHASES.INITIALISING,
checkingSetup: (phase) => phase === AUTH_PHASES.CHECKING_SETUP,
ready: (phase) => phase === AUTH_PHASES.READY,
};
/**
* Check if authentication is fully initialised and ready
* @param {string} phase - Current auth phase
* @param {boolean} isAuthenticated - Whether user is authenticated
* @returns {boolean} - True if auth is ready for other contexts to use
*/
export const isAuthReady = (phase, isAuthenticated) => {
return isAuthPhase.ready(phase) && isAuthenticated;
};

View File

@@ -5,6 +5,8 @@ import {
useEffect,
useState,
} from "react";
import { flushSync } from "react-dom";
import { AUTH_PHASES, isAuthPhase } from "../constants/authPhases";
const AuthContext = createContext();
@@ -20,11 +22,11 @@ export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [permissions, setPermissions] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [permissionsLoading, setPermissionsLoading] = useState(false);
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false);
const [checkingSetup, setCheckingSetup] = useState(true);
// Authentication state machine phases
const [authPhase, setAuthPhase] = useState(AUTH_PHASES.INITIALISING);
const [permissionsLoading, setPermissionsLoading] = useState(false);
// Define functions first
const fetchPermissions = useCallback(async (authToken) => {
@@ -39,7 +41,6 @@ export const AuthProvider = ({ children }) => {
if (response.ok) {
const data = await response.json();
setPermissions(data);
localStorage.setItem("permissions", JSON.stringify(data));
return data;
} else {
console.error("Failed to fetch permissions");
@@ -65,36 +66,28 @@ export const AuthProvider = ({ children }) => {
useEffect(() => {
const storedToken = localStorage.getItem("token");
const storedUser = localStorage.getItem("user");
const storedPermissions = localStorage.getItem("permissions");
if (storedToken && storedUser) {
try {
setToken(storedToken);
setUser(JSON.parse(storedUser));
if (storedPermissions) {
setPermissions(JSON.parse(storedPermissions));
} else {
// Use the proper fetchPermissions function
fetchPermissions(storedToken);
}
// Fetch permissions from backend
fetchPermissions(storedToken);
// User is authenticated, skip setup check
setAuthPhase(AUTH_PHASES.READY);
} catch (error) {
console.error("Error parsing stored user data:", error);
localStorage.removeItem("token");
localStorage.removeItem("user");
localStorage.removeItem("permissions");
// Move to setup check phase
setAuthPhase(AUTH_PHASES.CHECKING_SETUP);
}
} else {
// No stored auth, check if setup is needed
setAuthPhase(AUTH_PHASES.CHECKING_SETUP);
}
setIsLoading(false);
}, [fetchPermissions]);
// Refresh permissions when user logs in (no automatic refresh)
useEffect(() => {
if (token && user) {
// Only refresh permissions once when user logs in
refreshPermissions();
}
}, [token, user, refreshPermissions]);
const login = async (username, password) => {
try {
const response = await fetch("/api/v1/auth/login", {
@@ -108,6 +101,12 @@ export const AuthProvider = ({ children }) => {
const data = await response.json();
if (response.ok) {
// Check if TFA is required
if (data.requiresTfa) {
return { success: true, requiresTfa: true };
}
// Regular successful login
setToken(data.token);
setUser(data.user);
localStorage.setItem("token", data.token);
@@ -147,7 +146,6 @@ export const AuthProvider = ({ children }) => {
setPermissions(null);
localStorage.removeItem("token");
localStorage.removeItem("user");
localStorage.removeItem("permissions");
}
};
@@ -202,10 +200,6 @@ export const AuthProvider = ({ children }) => {
}
};
const isAuthenticated = () => {
return !!(token && user);
};
const isAdmin = () => {
return user?.role === "admin";
};
@@ -243,42 +237,59 @@ export const AuthProvider = ({ children }) => {
if (response.ok) {
const data = await response.json();
setNeedsFirstTimeSetup(!data.hasAdminUsers);
setAuthPhase(AUTH_PHASES.READY); // Setup check complete, move to ready phase
} else {
// If endpoint doesn't exist or fails, assume setup is needed
setNeedsFirstTimeSetup(true);
setAuthPhase(AUTH_PHASES.READY);
}
} catch (error) {
console.error("Error checking admin users:", error);
// If there's an error, assume setup is needed
setNeedsFirstTimeSetup(true);
} finally {
setCheckingSetup(false);
setAuthPhase(AUTH_PHASES.READY);
}
}, []);
// Check for admin users on initial load
// Check for admin users ONLY when in CHECKING_SETUP phase
useEffect(() => {
if (!token && !user) {
if (isAuthPhase.checkingSetup(authPhase)) {
checkAdminUsersExist();
} else {
setCheckingSetup(false);
}
}, [token, user, checkAdminUsersExist]);
}, [authPhase, checkAdminUsersExist]);
const setAuthState = (authToken, authUser) => {
setToken(authToken);
setUser(authUser);
// Use flushSync to ensure all state updates are applied synchronously
flushSync(() => {
setToken(authToken);
setUser(authUser);
setNeedsFirstTimeSetup(false);
setAuthPhase(AUTH_PHASES.READY);
});
// Store in localStorage after state is updated
localStorage.setItem("token", authToken);
localStorage.setItem("user", JSON.stringify(authUser));
// Fetch permissions immediately for the new authenticated user
fetchPermissions(authToken);
};
// Computed loading state based on phase and permissions state
const isLoading = !isAuthPhase.ready(authPhase) || permissionsLoading;
// Function to check authentication status (maintains API compatibility)
const isAuthenticated = () => {
return !!(user && token && isAuthPhase.ready(authPhase));
};
const value = {
user,
token,
permissions,
isLoading: isLoading || permissionsLoading || checkingSetup,
isLoading,
needsFirstTimeSetup,
checkingSetup,
authPhase,
login,
logout,
updateProfile,

View File

@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useState } from "react";
import { createContext, useContext, useMemo, useState } from "react";
import { isAuthReady } from "../constants/authPhases";
import { settingsAPI, versionAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
@@ -17,16 +18,26 @@ export const useUpdateNotification = () => {
export const UpdateNotificationProvider = ({ children }) => {
const [dismissed, setDismissed] = useState(false);
const { user, token } = useAuth();
const { authPhase, isAuthenticated } = useAuth();
// Ensure settings are loaded
// Ensure settings are loaded - but only after auth is fully ready
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
enabled: !!(user && token),
retry: 1,
staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
refetchOnWindowFocus: false,
enabled: isAuthReady(authPhase, isAuthenticated()),
});
// Memoize the enabled condition to prevent unnecessary re-evaluations
const isQueryEnabled = useMemo(() => {
return (
isAuthReady(authPhase, isAuthenticated()) &&
!!settings &&
!settingsLoading
);
}, [authPhase, isAuthenticated, settings, settingsLoading]);
// Query for update information
const {
data: updateData,
@@ -38,7 +49,7 @@ export const UpdateNotificationProvider = ({ children }) => {
staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
retry: 1,
enabled: !!(user && token && settings && !settingsLoading), // Only run when authenticated and settings are loaded
enabled: isQueryEnabled,
});
const updateAvailable = updateData?.isUpdateAvailable && !dismissed;

View File

@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx";
@@ -17,11 +17,11 @@ const queryClient = new QueryClient({
});
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
</StrictMode>,
);

View File

@@ -44,11 +44,7 @@ const HostDetail = () => {
const [showCredentialsModal, setShowCredentialsModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showAllUpdates, setShowAllUpdates] = useState(false);
const [activeTab, setActiveTab] = useState(() => {
// Restore tab state from localStorage
const savedTab = localStorage.getItem(`host-detail-tab-${hostId}`);
return savedTab || "host";
});
const [activeTab, setActiveTab] = useState("host");
const {
data: host,
@@ -63,10 +59,9 @@ const HostDetail = () => {
refetchOnWindowFocus: false, // Don't refetch when window regains focus
});
// Save tab state to localStorage when it changes
// Tab change handler
const handleTabChange = (tabName) => {
setActiveTab(tabName);
localStorage.setItem(`host-detail-tab-${hostId}`, tabName);
};
// Auto-show credentials modal for new/pending hosts
@@ -84,7 +79,7 @@ const HostDetail = () => {
},
});
// Toggle auto-update mutation
// Toggle agent auto-update mutation (updates PatchMon agent script, not system packages)
const toggleAutoUpdateMutation = useMutation({
mutationFn: (auto_update) =>
adminHostsAPI
@@ -350,7 +345,7 @@ const HostDetail = () => {
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Friendly Name
</p>
<InlineEdit
@@ -374,7 +369,7 @@ const HostDetail = () => {
{host.hostname && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
System Hostname
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
@@ -384,7 +379,7 @@ const HostDetail = () => {
)}
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Host Group
</p>
{host.host_groups ? (
@@ -402,7 +397,7 @@ const HostDetail = () => {
</div>
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Operating System
</p>
<div className="flex items-center gap-2">
@@ -413,43 +408,38 @@ const HostDetail = () => {
</div>
</div>
{host.agent_version && (
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Agent Version
</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.agent_version}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-secondary-500 dark:text-secondary-300">
Auto-update
</span>
<button
type="button"
onClick={() =>
toggleAutoUpdateMutation.mutate(!host.auto_update)
}
disabled={toggleAutoUpdateMutation.isPending}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
host.auto_update
? "bg-primary-600 dark:bg-primary-500"
: "bg-secondary-200 dark:bg-secondary-600"
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
host.auto_update
? "translate-x-5"
: "translate-x-1"
}`}
/>
</button>
</div>
</div>
)}
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Agent Version
</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.agent_version || "Unknown"}
</p>
</div>
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Auto-update
</p>
<button
type="button"
onClick={() =>
toggleAutoUpdateMutation.mutate(!host.auto_update)
}
disabled={toggleAutoUpdateMutation.isPending}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
host.auto_update
? "bg-primary-600 dark:bg-primary-500"
: "bg-secondary-200 dark:bg-secondary-600"
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
host.auto_update ? "translate-x-5" : "translate-x-1"
}`}
/>
</button>
</div>
</div>
</div>
)}

View File

@@ -326,7 +326,12 @@ const Hosts = () => {
{ id: "os", label: "OS", visible: true, order: 4 },
{ id: "os_version", label: "OS Version", visible: false, order: 5 },
{ id: "agent_version", label: "Agent Version", visible: true, order: 6 },
{ id: "auto_update", label: "Auto-update", visible: true, order: 7 },
{
id: "auto_update",
label: "Agent Auto-Update",
visible: true,
order: 7,
},
{ id: "status", label: "Status", visible: true, order: 8 },
{ id: "updates", label: "Updates", visible: true, order: 9 },
{ id: "last_update", label: "Last Update", visible: true, order: 10 },

View File

@@ -67,23 +67,17 @@ const Login = () => {
setError("");
try {
const response = await authAPI.login(
formData.username,
formData.password,
);
// Use the AuthContext login function which handles everything
const result = await login(formData.username, formData.password);
if (response.data.requiresTfa) {
if (result.requiresTfa) {
setRequiresTfa(true);
setTfaUsername(formData.username);
setError("");
} else if (result.success) {
navigate("/");
} else {
// Regular login successful
const result = await login(formData.username, formData.password);
if (result.success) {
navigate("/");
} else {
setError(result.error || "Login failed");
}
setError(result.error || "Login failed");
}
} catch (err) {
setError(err.response?.data?.error || "Login failed");

View File

@@ -39,7 +39,6 @@ api.interceptors.response.use(
// Handle unauthorized
localStorage.removeItem("token");
localStorage.removeItem("user");
localStorage.removeItem("permissions");
window.location.href = "/login";
}
}

8
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"@biomejs/biome": "2.2.4",
"@commitlint/cli": "^20.0.0",
"@commitlint/config-conventional": "^20.0.0",
"@types/bcryptjs": "^2.4.6",
"concurrently": "^8.2.2",
"lefthook": "^1.13.4",
"lint-staged": "^15.2.10",
@@ -1337,6 +1338,13 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/conventional-commits-parser": {
"version": "5.0.1",
"dev": true,

View File

@@ -26,6 +26,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@types/bcryptjs": "^2.4.6",
"@commitlint/cli": "^20.0.0",
"@commitlint/config-conventional": "^20.0.0",
"concurrently": "^8.2.2",