1.3.2 final

fixed theme and user profile settings
This commit is contained in:
9 Technology Group LTD
2025-10-31 22:25:22 +00:00
committed by GitHub
6 changed files with 176 additions and 85 deletions

View File

@@ -860,6 +860,9 @@ router.post(
last_login: user.last_login, last_login: user.last_login,
created_at: user.created_at, created_at: user.created_at,
updated_at: user.updated_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) { } catch (error) {
@@ -952,10 +955,24 @@ router.post(
return res.status(401).json({ error: "Invalid verification code" }); return res.status(401).json({ error: "Invalid verification code" });
} }
// Update last login // Update last login and fetch complete user data
await prisma.users.update({ const updatedUser = await prisma.users.update({
where: { id: user.id }, where: { id: user.id },
data: { last_login: new Date() }, 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 // Create session with access and refresh tokens
@@ -975,14 +992,7 @@ router.post(
refresh_token: session.refresh_token, refresh_token: session.refresh_token,
expires_at: session.expires_at, expires_at: session.expires_at,
tfa_bypass_until: session.tfa_bypass_until, tfa_bypass_until: session.tfa_bypass_until,
user: { user: updatedUser,
id: user.id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
},
}); });
} catch (error) { } catch (error) {
console.error("TFA verification error:", error); console.error("TFA verification error:", error);
@@ -1014,13 +1024,27 @@ router.put(
.withMessage("Username must be at least 3 characters"), .withMessage("Username must be at least 3 characters"),
body("email").optional().isEmail().withMessage("Valid email is required"), body("email").optional().isEmail().withMessage("Valid email is required"),
body("first_name") body("first_name")
.optional() .optional({ nullable: true, checkFalsy: true })
.isLength({ min: 1 }) .custom((value) => {
.withMessage("First name must be at least 1 character"), // 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") body("last_name")
.optional() .optional({ nullable: true, checkFalsy: true })
.isLength({ min: 1 }) .custom((value) => {
.withMessage("Last name must be at least 1 character"), // 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) => { async (req, res) => {
try { try {
@@ -1034,16 +1058,22 @@ router.put(
updated_at: new Date(), updated_at: new Date(),
}; };
if (username) updateData.username = username; // Handle all fields consistently - trim and update if provided
if (email) updateData.email = email; if (username) updateData.username = username.trim();
// Handle first_name and last_name - allow empty strings to clear the field if (email) updateData.email = email.trim();
if (first_name !== undefined) { if (first_name !== undefined) {
// Allow null or empty string to clear the field, otherwise trim
updateData.first_name = updateData.first_name =
first_name === "" ? null : first_name.trim() || null; first_name === "" || first_name === null
? null
: first_name.trim() || null;
} }
if (last_name !== undefined) { if (last_name !== undefined) {
// Allow null or empty string to clear the field, otherwise trim
updateData.last_name = 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) // Check if username/email already exists (excluding current user)
@@ -1106,16 +1136,6 @@ router.put(
// Use fresh data if available, otherwise fallback to updatedUser // Use fresh data if available, otherwise fallback to updatedUser
const responseUser = freshUser || 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({ res.json({
message: "Profile updated successfully", message: "Profile updated successfully",
user: responseUser, user: responseUser,

View File

@@ -450,8 +450,8 @@ function AppRoutes() {
function App() { function App() {
return ( return (
<ThemeProvider>
<AuthProvider> <AuthProvider>
<ThemeProvider>
<SettingsProvider> <SettingsProvider>
<ColorThemeProvider> <ColorThemeProvider>
<UpdateNotificationProvider> <UpdateNotificationProvider>
@@ -461,8 +461,8 @@ function App() {
</UpdateNotificationProvider> </UpdateNotificationProvider>
</ColorThemeProvider> </ColorThemeProvider>
</SettingsProvider> </SettingsProvider>
</AuthProvider>
</ThemeProvider> </ThemeProvider>
</AuthProvider>
); );
} }

View File

@@ -138,6 +138,9 @@ export const AuthProvider = ({ children }) => {
setPermissions(userPermissions); setPermissions(userPermissions);
} }
// Note: User preferences will be automatically fetched by ColorThemeContext
// when the component mounts, so no need to invalidate here
return { success: true }; return { success: true };
} else { } else {
// Handle HTTP error responses (like 500 CORS errors) // Handle HTTP error responses (like 500 CORS errors)
@@ -224,8 +227,6 @@ export const AuthProvider = ({ children }) => {
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
console.log("Profile updated - received user data:", data.user);
// Validate that we received user data with expected fields // Validate that we received user data with expected fields
if (!data.user || !data.user.id) { if (!data.user || !data.user.id) {
console.error("Invalid user data in response:", data); console.error("Invalid user data in response:", data);
@@ -239,15 +240,6 @@ export const AuthProvider = ({ children }) => {
setUser(data.user); setUser(data.user);
localStorage.setItem("user", JSON.stringify(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 }; return { success: true, user: data.user };
} else { } else {
// Handle HTTP error responses (like 500 CORS errors) // Handle HTTP error responses (like 500 CORS errors)

View File

@@ -1,6 +1,15 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useState } from "react"; import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { userPreferencesAPI } from "../utils/api"; import { userPreferencesAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
const ColorThemeContext = createContext(); const ColorThemeContext = createContext();
@@ -123,48 +132,108 @@ export const THEME_PRESETS = {
}; };
export const ColorThemeProvider = ({ children }) => { export const ColorThemeProvider = ({ children }) => {
const [colorTheme, setColorTheme] = useState(() => { const queryClient = useQueryClient();
// Initialize from localStorage for immediate render const lastThemeRef = useRef(null);
return localStorage.getItem("colorTheme") || "cyber_blue";
});
const [isLoading, setIsLoading] = useState(true);
// Fetch user preferences from backend // Use reactive authentication state from AuthContext
const { data: userPreferences } = useQuery({ // 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"], queryKey: ["userPreferences"],
queryFn: () => userPreferencesAPI.get().then((res) => res.data), 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 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(() => { useEffect(() => {
if (userPreferences?.color_theme) { if (userPreferences?.color_theme) {
setColorTheme(userPreferences.color_theme);
localStorage.setItem("colorTheme", userPreferences.color_theme); localStorage.setItem("colorTheme", userPreferences.color_theme);
} }
setIsLoading(false); }, [userPreferences?.color_theme]);
}, [userPreferences]);
const updateColorTheme = async (theme) => { const updateColorTheme = useCallback(
async (theme) => {
// Store previous theme for potential revert
const previousTheme = colorTheme;
// Immediately update state for instant UI feedback
setColorTheme(theme); setColorTheme(theme);
lastThemeRef.current = theme;
// Also update localStorage cache
localStorage.setItem("colorTheme", theme); localStorage.setItem("colorTheme", theme);
// Save to backend // Save to backend (source of truth)
try { try {
await userPreferencesAPI.update({ color_theme: theme }); await userPreferencesAPI.update({ color_theme: theme });
// Invalidate and refetch user preferences to ensure sync across tabs/browsers
await queryClient.invalidateQueries({ queryKey: ["userPreferences"] });
} catch (error) { } catch (error) {
console.error("Failed to save color theme preference:", error); console.error("Failed to save color theme preference:", error);
// Theme is already set locally, so user still sees the change // Revert to previous theme if save failed
} setColorTheme(previousTheme);
}; lastThemeRef.current = previousTheme;
localStorage.setItem("colorTheme", previousTheme);
const value = { // 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, colorTheme,
setColorTheme: updateColorTheme, setColorTheme: updateColorTheme,
themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default, themeConfig,
isLoading, isLoading,
}; }),
[colorTheme, themeConfig, isLoading, updateColorTheme],
);
return ( return (
<ColorThemeContext.Provider value={value}> <ColorThemeContext.Provider value={value}>

View File

@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
import { userPreferencesAPI } from "../utils/api"; import { userPreferencesAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
const ThemeContext = createContext(); const ThemeContext = createContext();
@@ -26,21 +27,29 @@ export const ThemeProvider = ({ children }) => {
return "light"; 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({ const { data: userPreferences } = useQuery({
queryKey: ["userPreferences"], queryKey: ["userPreferences"],
queryFn: () => userPreferencesAPI.get().then((res) => res.data), queryFn: () => userPreferencesAPI.get().then((res) => res.data),
enabled: isAuthenticated, // Only run query if user is authenticated
retry: 1, retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
}); });
// Sync with user preferences from backend // Sync with user preferences from backend or user object from login
useEffect(() => { useEffect(() => {
if (userPreferences?.theme_preference) { const preferredTheme =
setTheme(userPreferences.theme_preference); userPreferences?.theme_preference || user?.theme_preference;
localStorage.setItem("theme", userPreferences.theme_preference); if (preferredTheme) {
setTheme(preferredTheme);
localStorage.setItem("theme", preferredTheme);
} }
}, [userPreferences]); }, [userPreferences, user?.theme_preference]);
useEffect(() => { useEffect(() => {
// Apply theme to document // Apply theme to document

View File

@@ -248,7 +248,8 @@ const Login = () => {
} catch (error) { } catch (error) {
console.error("Failed to fetch GitHub data:", error); console.error("Failed to fetch GitHub data:", error);
// Set fallback data if nothing cached // Set fallback data if nothing cached
if (!latestRelease) { const cachedRelease = localStorage.getItem("githubLatestRelease");
if (!cachedRelease) {
setLatestRelease({ setLatestRelease({
version: "v1.3.0", version: "v1.3.0",
name: "Latest Release", name: "Latest Release",
@@ -260,7 +261,7 @@ const Login = () => {
}; };
fetchGitHubData(); fetchGitHubData();
}, [latestRelease]); }, []); // Run once on mount
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();