diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js
index a415459..915fc15 100644
--- a/backend/src/routes/authRoutes.js
+++ b/backend/src/routes/authRoutes.js
@@ -860,6 +860,9 @@ router.post(
last_login: user.last_login,
created_at: user.created_at,
updated_at: user.updated_at,
+ // Include user preferences so they're available immediately after login
+ theme_preference: user.theme_preference,
+ color_theme: user.color_theme,
},
});
} catch (error) {
@@ -952,10 +955,24 @@ router.post(
return res.status(401).json({ error: "Invalid verification code" });
}
- // Update last login
- await prisma.users.update({
+ // Update last login and fetch complete user data
+ const updatedUser = await prisma.users.update({
where: { id: user.id },
data: { last_login: new Date() },
+ select: {
+ id: true,
+ username: true,
+ email: true,
+ first_name: true,
+ last_name: true,
+ role: true,
+ is_active: true,
+ last_login: true,
+ created_at: true,
+ updated_at: true,
+ theme_preference: true,
+ color_theme: true,
+ },
});
// Create session with access and refresh tokens
@@ -975,14 +992,7 @@ router.post(
refresh_token: session.refresh_token,
expires_at: session.expires_at,
tfa_bypass_until: session.tfa_bypass_until,
- user: {
- id: user.id,
- username: user.username,
- email: user.email,
- first_name: user.first_name,
- last_name: user.last_name,
- role: user.role,
- },
+ user: updatedUser,
});
} catch (error) {
console.error("TFA verification error:", error);
@@ -1014,13 +1024,27 @@ router.put(
.withMessage("Username must be at least 3 characters"),
body("email").optional().isEmail().withMessage("Valid email is required"),
body("first_name")
- .optional()
- .isLength({ min: 1 })
- .withMessage("First name must be at least 1 character"),
+ .optional({ nullable: true, checkFalsy: true })
+ .custom((value) => {
+ // Allow null, undefined, or empty string to clear the field
+ if (value === null || value === undefined || value === "") {
+ return true;
+ }
+ // If provided, must be at least 1 character after trimming
+ return typeof value === "string" && value.trim().length >= 1;
+ })
+ .withMessage("First name must be at least 1 character if provided"),
body("last_name")
- .optional()
- .isLength({ min: 1 })
- .withMessage("Last name must be at least 1 character"),
+ .optional({ nullable: true, checkFalsy: true })
+ .custom((value) => {
+ // Allow null, undefined, or empty string to clear the field
+ if (value === null || value === undefined || value === "") {
+ return true;
+ }
+ // If provided, must be at least 1 character after trimming
+ return typeof value === "string" && value.trim().length >= 1;
+ })
+ .withMessage("Last name must be at least 1 character if provided"),
],
async (req, res) => {
try {
@@ -1034,16 +1058,22 @@ router.put(
updated_at: new Date(),
};
- if (username) updateData.username = username;
- if (email) updateData.email = email;
- // Handle first_name and last_name - allow empty strings to clear the field
+ // Handle all fields consistently - trim and update if provided
+ if (username) updateData.username = username.trim();
+ if (email) updateData.email = email.trim();
if (first_name !== undefined) {
+ // Allow null or empty string to clear the field, otherwise trim
updateData.first_name =
- first_name === "" ? null : first_name.trim() || null;
+ first_name === "" || first_name === null
+ ? null
+ : first_name.trim() || null;
}
if (last_name !== undefined) {
+ // Allow null or empty string to clear the field, otherwise trim
updateData.last_name =
- last_name === "" ? null : last_name.trim() || null;
+ last_name === "" || last_name === null
+ ? null
+ : last_name.trim() || null;
}
// Check if username/email already exists (excluding current user)
@@ -1106,16 +1136,6 @@ router.put(
// Use fresh data if available, otherwise fallback to updatedUser
const responseUser = freshUser || updatedUser;
- // Log update for debugging (only log in non-production)
- if (process.env.NODE_ENV !== "production") {
- console.log("Profile updated:", {
- userId: req.user.id,
- first_name: responseUser.first_name,
- last_name: responseUser.last_name,
- updated_at: responseUser.updated_at,
- });
- }
-
res.json({
message: "Profile updated successfully",
user: responseUser,
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 6eaf7b1..dbafd2a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -450,8 +450,8 @@ function AppRoutes() {
function App() {
return (
-
-
+
+
@@ -461,8 +461,8 @@ function App() {
-
-
+
+
);
}
diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx
index 581788b..3cb6496 100644
--- a/frontend/src/contexts/AuthContext.jsx
+++ b/frontend/src/contexts/AuthContext.jsx
@@ -138,6 +138,9 @@ export const AuthProvider = ({ children }) => {
setPermissions(userPermissions);
}
+ // Note: User preferences will be automatically fetched by ColorThemeContext
+ // when the component mounts, so no need to invalidate here
+
return { success: true };
} else {
// Handle HTTP error responses (like 500 CORS errors)
@@ -224,8 +227,6 @@ export const AuthProvider = ({ children }) => {
const data = await response.json();
if (response.ok) {
- console.log("Profile updated - received user data:", data.user);
-
// Validate that we received user data with expected fields
if (!data.user || !data.user.id) {
console.error("Invalid user data in response:", data);
@@ -239,15 +240,6 @@ export const AuthProvider = ({ children }) => {
setUser(data.user);
localStorage.setItem("user", JSON.stringify(data.user));
- // Log update for debugging (only in non-production)
- if (process.env.NODE_ENV !== "production") {
- console.log("User data updated in localStorage:", {
- id: data.user.id,
- first_name: data.user.first_name,
- last_name: data.user.last_name,
- });
- }
-
return { success: true, user: data.user };
} else {
// Handle HTTP error responses (like 500 CORS errors)
diff --git a/frontend/src/contexts/ColorThemeContext.jsx b/frontend/src/contexts/ColorThemeContext.jsx
index 1781343..62e8caf 100644
--- a/frontend/src/contexts/ColorThemeContext.jsx
+++ b/frontend/src/contexts/ColorThemeContext.jsx
@@ -1,6 +1,15 @@
-import { useQuery } from "@tanstack/react-query";
-import { createContext, useContext, useEffect, useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { userPreferencesAPI } from "../utils/api";
+import { useAuth } from "./AuthContext";
const ColorThemeContext = createContext();
@@ -123,48 +132,108 @@ export const THEME_PRESETS = {
};
export const ColorThemeProvider = ({ children }) => {
- const [colorTheme, setColorTheme] = useState(() => {
- // Initialize from localStorage for immediate render
- return localStorage.getItem("colorTheme") || "cyber_blue";
- });
- const [isLoading, setIsLoading] = useState(true);
+ const queryClient = useQueryClient();
+ const lastThemeRef = useRef(null);
- // Fetch user preferences from backend
- const { data: userPreferences } = useQuery({
+ // Use reactive authentication state from AuthContext
+ // This ensures the query re-enables when user logs in
+ const { user } = useAuth();
+ const isAuthenticated = !!user;
+
+ // Source of truth: Database (via userPreferences query)
+ // localStorage is only used as a temporary cache until DB loads
+ // Only fetch if user is authenticated to avoid 401 errors on login page
+ const { data: userPreferences, isLoading: preferencesLoading } = useQuery({
queryKey: ["userPreferences"],
queryFn: () => userPreferencesAPI.get().then((res) => res.data),
- retry: 1,
+ enabled: isAuthenticated, // Only run query if user is authenticated
+ retry: 2,
staleTime: 5 * 60 * 1000, // 5 minutes
+ refetchOnWindowFocus: true, // Refetch when user returns to tab
});
- // Update theme when preferences are loaded
+ // Get theme from database (source of truth), fallback to user object from login, then localStorage cache, then default
+ // Memoize to prevent recalculation on every render
+ const colorThemeValue = useMemo(() => {
+ return (
+ userPreferences?.color_theme ||
+ user?.color_theme ||
+ localStorage.getItem("colorTheme") ||
+ "cyber_blue"
+ );
+ }, [userPreferences?.color_theme, user?.color_theme]);
+
+ // Only update state if the theme value actually changed (prevent loops)
+ const [colorTheme, setColorTheme] = useState(() => colorThemeValue);
+
+ useEffect(() => {
+ // Only update if the value actually changed from what we last saw (prevent loops)
+ if (colorThemeValue !== lastThemeRef.current) {
+ setColorTheme(colorThemeValue);
+ lastThemeRef.current = colorThemeValue;
+ }
+ }, [colorThemeValue]);
+
+ const isLoading = preferencesLoading;
+
+ // Sync localStorage cache when DB data is available (for offline/performance)
useEffect(() => {
if (userPreferences?.color_theme) {
- setColorTheme(userPreferences.color_theme);
localStorage.setItem("colorTheme", userPreferences.color_theme);
}
- setIsLoading(false);
- }, [userPreferences]);
+ }, [userPreferences?.color_theme]);
- const updateColorTheme = async (theme) => {
- setColorTheme(theme);
- localStorage.setItem("colorTheme", theme);
+ const updateColorTheme = useCallback(
+ async (theme) => {
+ // Store previous theme for potential revert
+ const previousTheme = colorTheme;
- // Save to backend
- try {
- await userPreferencesAPI.update({ color_theme: theme });
- } catch (error) {
- console.error("Failed to save color theme preference:", error);
- // Theme is already set locally, so user still sees the change
- }
- };
+ // Immediately update state for instant UI feedback
+ setColorTheme(theme);
+ lastThemeRef.current = theme;
- const value = {
- colorTheme,
- setColorTheme: updateColorTheme,
- themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
- isLoading,
- };
+ // Also update localStorage cache
+ localStorage.setItem("colorTheme", theme);
+
+ // Save to backend (source of truth)
+ try {
+ await userPreferencesAPI.update({ color_theme: theme });
+
+ // Invalidate and refetch user preferences to ensure sync across tabs/browsers
+ await queryClient.invalidateQueries({ queryKey: ["userPreferences"] });
+ } catch (error) {
+ console.error("Failed to save color theme preference:", error);
+ // Revert to previous theme if save failed
+ setColorTheme(previousTheme);
+ lastThemeRef.current = previousTheme;
+ localStorage.setItem("colorTheme", previousTheme);
+
+ // Invalidate to refresh from DB
+ await queryClient.invalidateQueries({ queryKey: ["userPreferences"] });
+
+ // Show error to user if possible (could add toast notification here)
+ throw error; // Re-throw so calling code can handle it
+ }
+ },
+ [colorTheme, queryClient],
+ );
+
+ // Memoize themeConfig to prevent unnecessary re-renders
+ const themeConfig = useMemo(
+ () => THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
+ [colorTheme],
+ );
+
+ // Memoize the context value to prevent unnecessary re-renders
+ const value = useMemo(
+ () => ({
+ colorTheme,
+ setColorTheme: updateColorTheme,
+ themeConfig,
+ isLoading,
+ }),
+ [colorTheme, themeConfig, isLoading, updateColorTheme],
+ );
return (
diff --git a/frontend/src/contexts/ThemeContext.jsx b/frontend/src/contexts/ThemeContext.jsx
index 161279b..5f2ed33 100644
--- a/frontend/src/contexts/ThemeContext.jsx
+++ b/frontend/src/contexts/ThemeContext.jsx
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useState } from "react";
import { userPreferencesAPI } from "../utils/api";
+import { useAuth } from "./AuthContext";
const ThemeContext = createContext();
@@ -26,21 +27,29 @@ export const ThemeProvider = ({ children }) => {
return "light";
});
- // Fetch user preferences from backend
+ // Use reactive authentication state from AuthContext
+ // This ensures the query re-enables when user logs in
+ const { user } = useAuth();
+ const isAuthenticated = !!user;
+
+ // Fetch user preferences from backend (only if authenticated)
const { data: userPreferences } = useQuery({
queryKey: ["userPreferences"],
queryFn: () => userPreferencesAPI.get().then((res) => res.data),
+ enabled: isAuthenticated, // Only run query if user is authenticated
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
});
- // Sync with user preferences from backend
+ // Sync with user preferences from backend or user object from login
useEffect(() => {
- if (userPreferences?.theme_preference) {
- setTheme(userPreferences.theme_preference);
- localStorage.setItem("theme", userPreferences.theme_preference);
+ const preferredTheme =
+ userPreferences?.theme_preference || user?.theme_preference;
+ if (preferredTheme) {
+ setTheme(preferredTheme);
+ localStorage.setItem("theme", preferredTheme);
}
- }, [userPreferences]);
+ }, [userPreferences, user?.theme_preference]);
useEffect(() => {
// Apply theme to document
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
index 3938358..5eb00e4 100644
--- a/frontend/src/pages/Login.jsx
+++ b/frontend/src/pages/Login.jsx
@@ -248,7 +248,8 @@ const Login = () => {
} catch (error) {
console.error("Failed to fetch GitHub data:", error);
// Set fallback data if nothing cached
- if (!latestRelease) {
+ const cachedRelease = localStorage.getItem("githubLatestRelease");
+ if (!cachedRelease) {
setLatestRelease({
version: "v1.3.0",
name: "Latest Release",
@@ -260,7 +261,7 @@ const Login = () => {
};
fetchGitHubData();
- }, [latestRelease]);
+ }, []); // Run once on mount
const handleSubmit = async (e) => {
e.preventDefault();