diff --git a/frontend/src/components/FirstTimeAdminSetup.jsx b/frontend/src/components/FirstTimeAdminSetup.jsx
index 38a8877..5d34624 100644
--- a/frontend/src/components/FirstTimeAdminSetup.jsx
+++ b/frontend/src/components/FirstTimeAdminSetup.jsx
@@ -3,7 +3,7 @@ import { useId, useState } from "react";
import { useAuth } from "../contexts/AuthContext";
const FirstTimeAdminSetup = () => {
- const { login } = useAuth();
+ const { login, setAuthState } = useAuth();
const firstNameId = useId();
const lastNameId = useId();
const usernameId = useId();
@@ -95,10 +95,18 @@ 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) {
+ // Auto-login using the token from the setup response
+ setAuthState(data.token, data.user);
+ setTimeout(() => {}, 2000);
+ } else {
+ // Fallback to manual login if no token provided
+ setTimeout(() => {
+ login(formData.username.trim(), formData.password);
+ }, 2000);
+ }
} else {
setError(data.error || "Failed to create admin user");
}
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
index fcda7d2..90f212c 100644
--- a/frontend/src/components/Layout.jsx
+++ b/frontend/src/components/Layout.jsx
@@ -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);
diff --git a/frontend/src/constants/authPhases.js b/frontend/src/constants/authPhases.js
new file mode 100644
index 0000000..e5acecf
--- /dev/null
+++ b/frontend/src/constants/authPhases.js
@@ -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;
+};
diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx
index 17357e8..bf2cf87 100644
--- a/frontend/src/contexts/AuthContext.jsx
+++ b/frontend/src/contexts/AuthContext.jsx
@@ -5,6 +5,7 @@ import {
useEffect,
useState,
} from "react";
+import { AUTH_PHASES, isAuthPhase } from "../constants/authPhases";
const AuthContext = createContext();
@@ -20,11 +21,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) => {
@@ -77,14 +78,20 @@ export const AuthProvider = ({ children }) => {
// Use the proper fetchPermissions function
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)
@@ -202,10 +209,6 @@ export const AuthProvider = ({ children }) => {
}
};
- const isAuthenticated = () => {
- return !!(token && user);
- };
-
const isAdmin = () => {
return user?.role === "admin";
};
@@ -243,42 +246,50 @@ 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);
localStorage.setItem("token", authToken);
localStorage.setItem("user", JSON.stringify(authUser));
+ setAuthPhase(AUTH_PHASES.READY); // Authentication complete, move to ready phase
+ };
+
+ // 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,
diff --git a/frontend/src/contexts/UpdateNotificationContext.jsx b/frontend/src/contexts/UpdateNotificationContext.jsx
index 84d2fe0..cb57f5d 100644
--- a/frontend/src/contexts/UpdateNotificationContext.jsx
+++ b/frontend/src/contexts/UpdateNotificationContext.jsx
@@ -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;
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 7666468..4136b64 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -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(
-
+
- ,
+ ,
);
diff --git a/package-lock.json b/package-lock.json
index 4cfd878..867bb21 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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,
diff --git a/package.json b/package.json
index b3329b6..5f9c7bb 100644
--- a/package.json
+++ b/package.json
@@ -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",