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();