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

@@ -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";
}
}